@remkoj/optimizely-cms-cli 6.0.0-pre9 → 6.0.0-rc.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/dist/index.js CHANGED
@@ -1,35 +1,52 @@
1
1
  import { globSync, glob, globIterate } from 'glob';
2
2
  import { config } from 'dotenv';
3
3
  import { expand } from 'dotenv-expand';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
4
6
  import { readPartialEnvConfig, createClient, OptiCmsVersion, ContentRoots } from '@remkoj/optimizely-cms-api';
5
7
  import yargs from 'yargs';
6
8
  import chalk from 'chalk';
7
- import path from 'node:path';
8
- import fs from 'node:fs';
9
9
  import figures from 'figures';
10
10
  import Table from 'cli-table3';
11
+ import slugify from '@sindresorhus/slugify';
12
+ import { diff } from 'deep-object-diff';
11
13
  import fsAsync from 'node:fs/promises';
12
14
  import { input, select, confirm } from '@inquirer/prompts';
13
15
  import createDeepMerge from '@fastify/deepmerge';
14
16
  import { Ajv } from 'ajv';
15
17
  import addFormats from 'ajv-formats';
16
- import GraphQLGen from '@remkoj/optimizely-graph-functions/contenttype-loader';
18
+ import { DocumentGenerator, PropertyCollisionTracker } from '@remkoj/optimizely-graph-functions/generate';
17
19
  import deepEqual from 'fast-deep-equal';
18
20
 
21
+ function getProjectDir() {
22
+ let testPath = process.cwd();
23
+ let hasPackageJson = false;
24
+ do {
25
+ hasPackageJson = fs.statSync(path.join(testPath, 'package.json'), { throwIfNoEntry: false })?.isFile() ?? false;
26
+ if (!hasPackageJson)
27
+ testPath = path.normalize(path.join(testPath, '..'));
28
+ } while (!hasPackageJson && testPath.length > 2);
29
+ if (!hasPackageJson) {
30
+ throw new Error('No package.json found!');
31
+ }
32
+ return testPath;
33
+ }
19
34
  /**
20
35
  * Prepare the application context, by parsing the .env files in the main
21
36
  * application directory.
22
37
  *
23
38
  * @returns A string array with the files processed
24
39
  */
25
- function prepare() {
26
- const globResult = globSync(".env*");
27
- const envFiles = globResult.sort((a, b) => b.length - a.length).filter(n => n == ".env" || n == ".env.local" || (process.env.NODE_ENV && n == `.env.${process.env.NODE_ENV}.local`));
40
+ function prepare(pDir) {
41
+ const projectDir = pDir ?? getProjectDir();
42
+ const envFiles = globSync(".env*", { cwd: projectDir })
43
+ .sort((a, b) => b.length - a.length)
44
+ .filter(n => n == ".env" || n == ".env.local" || (process.env.NODE_ENV && n == `.env.${process.env.NODE_ENV}.local`));
28
45
  expand(config({ path: envFiles, debug: false, quiet: true }));
29
46
  return envFiles;
30
47
  }
31
48
 
32
- function createOptiCmsApp(scriptName, version, epilogue, envFiles) {
49
+ function createOptiCmsApp(scriptName, version, epilogue, envFiles, projectDir) {
33
50
  if (envFiles) {
34
51
  process.stdout.write(chalk.bold(`✅ Loaded environment files:`) + "\n");
35
52
  envFiles.forEach(envFile => {
@@ -45,11 +62,12 @@ function createOptiCmsApp(scriptName, version, epilogue, envFiles) {
45
62
  console.error(chalk.redBright(`${chalk.bold("[ERROR]:")} Error processing environment variables: ${e.message}`));
46
63
  process.exit(1);
47
64
  }
65
+ const defaultPath = projectDir ?? process.cwd();
48
66
  return yargs(process.argv)
49
67
  .scriptName(scriptName)
50
68
  .version(version)
51
69
  .usage('$0 <cmd> [args]')
52
- .option("path", { alias: "p", description: "Application root folder", string: true, type: "string", demandOption: false, default: process.cwd() })
70
+ .option("path", { alias: "p", description: "Application root folder", string: true, type: "string", demandOption: false, default: defaultPath })
53
71
  .option("components", { alias: "c", description: "Path to components folder", string: true, type: "string", demandOption: false, default: "./src/components/cms" })
54
72
  .option("cms_url", { alias: "cu", description: "Optimizely CMS URL", string: true, type: "string", demandOption: isDemanded(config.base), default: config.base, coerce: (val) => val ? new URL(val) : undefined })
55
73
  .option("client_id", { alias: "ci", description: "API Client ID", string: true, type: "string", demandOption: isDemanded(config.clientId), default: config.clientId })
@@ -133,84 +151,6 @@ function getCmsIntegrationApiOptions(args) {
133
151
  return parseArgs(args)._config;
134
152
  }
135
153
 
136
- const StylesPushCommand = {
137
- command: "styles:push",
138
- describe: "Push Visual Builder style definitions into the CMS (create/replace)",
139
- builder: (yargs) => {
140
- yargs.option('excludeTemplates', { alias: 'e', description: "Exclude these templates", string: true, type: 'array', demandOption: false, default: [] });
141
- yargs.option("templates", { alias: 't', description: "Select only these templates", string: true, type: 'array', demandOption: false, default: [] });
142
- return yargs;
143
- },
144
- handler: async (args) => {
145
- const { _config: cfg, excludeTemplates, templates, ...opts } = parseArgs(args);
146
- const client = createCmsClient(args);
147
- if (client.runtimeCmsVersion == OptiCmsVersion.CMS12) {
148
- process.stdout.write(chalk.gray(`${figures.cross} Styles are not supported on CMS12\n`));
149
- return;
150
- }
151
- process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Pushing (create/replace) DisplayStyles into Optimizely CMS\n`));
152
- const styleDefinitionFiles = await glob("./**/*.opti-style.json", {
153
- cwd: opts.components
154
- });
155
- const results = (await Promise.all(styleDefinitionFiles.map(async (styleDefinitionFile) => {
156
- const filePath = path.normalize(path.join(opts.components, styleDefinitionFile));
157
- const styleDefinition = tryReadJsonFile(filePath, cfg.debug);
158
- const styleKey = styleDefinition.key;
159
- if (!styleKey) {
160
- process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} The style definition in ${path.relative(opts.path, filePath)} does not have a key defined\n`));
161
- return undefined;
162
- }
163
- if (excludeTemplates.includes(styleKey))
164
- return undefined; // Skip excluded styles
165
- if (templates.length > 0 && !templates.includes(styleKey))
166
- return undefined; // Only include defined styles, if any
167
- if (cfg.debug)
168
- process.stdout.write(chalk.gray(`${figures.arrowRight} Pushing: ${styleKey}\n`));
169
- // Try to fetch the current template
170
- const currentTemplate = await client.displayTemplatesGet({ path: { key: styleKey } }).catch(() => { return undefined; });
171
- // Create / Replace the current template
172
- const newTemplate = await (currentTemplate ?
173
- client.displayTemplatesPatch({ path: { key: styleKey }, body: styleDefinition }) :
174
- client.displayTemplatesCreate({ body: styleDefinition }));
175
- return newTemplate;
176
- }))).filter(isNotNullOrUndefined);
177
- const styles = new Table({
178
- head: [
179
- chalk.yellow(chalk.bold("Name")),
180
- chalk.yellow(chalk.bold("Key")),
181
- chalk.yellow(chalk.bold("Default")),
182
- chalk.yellow(chalk.bold("Target"))
183
- ],
184
- colWidths: [31, 20, 9, 20],
185
- colAligns: ["left", "left", "center", "left"]
186
- });
187
- results.forEach(tpl => {
188
- styles.push([
189
- tpl.displayName,
190
- tpl.key,
191
- tpl.isDefault ? figures.tick : figures.cross,
192
- tpl.contentType ? `${tpl.contentType} (C)` : tpl.baseType ? `${tpl.baseType} (B)` : `${tpl.nodeType} (N)`
193
- ]);
194
- });
195
- process.stdout.write(styles.toString() + "\n");
196
- process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
197
- }
198
- };
199
- function tryReadJsonFile(filePath, debug = false) {
200
- try {
201
- if (debug)
202
- process.stdout.write(chalk.gray(`${figures.arrowRight} Reading style definition from ${filePath}\n`));
203
- return JSON.parse(fs.readFileSync(filePath, { encoding: 'utf-8' }));
204
- }
205
- catch (e) {
206
- process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} Error while reading ${filePath}\n`));
207
- }
208
- return undefined;
209
- }
210
- function isNotNullOrUndefined(i) {
211
- return i ? true : false;
212
- }
213
-
214
154
  /**
215
155
  * This file contains tools that allow using the project that we're targeting
216
156
  */
@@ -223,8 +163,12 @@ function isNotNullOrUndefined(i) {
223
163
  * @returns
224
164
  */
225
165
  function getContentTypePaths(contentType, basePath, createFolder = false, debug = false) {
226
- const baseTypeSlug = typeToSlug(contentType.baseType);
227
- const typePath = path.join(basePath, baseTypeSlug, contentType.key);
166
+ const baseTypeSlug = keyToSlug(contentType.baseType, {
167
+ defaultKey: 'global',
168
+ stripLeadingUnderscore: true
169
+ });
170
+ const typeSlug = keyToSlug(contentType.key);
171
+ const typePath = path.join(basePath, baseTypeSlug, typeSlug);
228
172
  if (createFolder) {
229
173
  if (!fs.existsSync(typePath)) {
230
174
  fs.mkdirSync(typePath, { recursive: true });
@@ -235,10 +179,10 @@ function getContentTypePaths(contentType, basePath, createFolder = false, debug
235
179
  if (!fs.statSync(typePath).isDirectory())
236
180
  throw new Error(`The folder ${typePath} for Content-Type ${contentType.key} exists, but is not a directory!`);
237
181
  }
238
- const typeFile = path.join(typePath, `${contentType.key}.opti-type.json`);
239
- const fragmentFile = path.join(typePath, `${contentType.key}.${baseTypeSlug}.graphql`);
240
- const propertyFragmentFile = path.join(typePath, `${contentType.key}.property.graphql`);
241
- const queryFile = path.join(typePath, `get${contentType.key}Data.query.graphql`);
182
+ const typeFile = path.join(typePath, `${typeSlug}.opti-type.json`);
183
+ const fragmentFile = path.join(typePath, `${typeSlug}.${baseTypeSlug}.graphql`);
184
+ const propertyFragmentFile = path.join(typePath, `${typeSlug}.property.graphql`);
185
+ const queryFile = path.join(typePath, `${typeSlug}.query.graphql`);
242
186
  const componentFile = path.join(typePath, `index.tsx`);
243
187
  return {
244
188
  type: contentType.key,
@@ -251,16 +195,167 @@ function getContentTypePaths(contentType, basePath, createFolder = false, debug
251
195
  queryFile
252
196
  };
253
197
  }
198
+ function getStyleFilePathsSync(displayTemplate, contentBaseType = '_Component', basePath = './src/components/cms', createFolder = false) {
199
+ const displayTemplateType = getDisplayTemplateType(displayTemplate);
200
+ let target;
201
+ let groupPath;
202
+ switch (displayTemplateType) {
203
+ case 'base':
204
+ groupPath = path.join(keyToSlug(displayTemplate.baseType, { stripLeadingUnderscore: true }), 'styles');
205
+ target = displayTemplate.baseType;
206
+ break;
207
+ case 'node':
208
+ groupPath = path.join('nodes', keyToSlug(displayTemplate.nodeType));
209
+ target = displayTemplate.nodeType;
210
+ break;
211
+ case 'component':
212
+ const baseType = contentBaseType;
213
+ target = displayTemplate.contentType;
214
+ groupPath = path.join(keyToSlug(baseType, { stripLeadingUnderscore: true }), keyToSlug(displayTemplate.contentType, { stripLeadingGroup: true }));
215
+ break;
216
+ default:
217
+ throw new Error(`Unsupported DisplayTemplate target for ${displayTemplate.key}`);
218
+ }
219
+ const displayTemplateKeySlug = keyToSlug(displayTemplate.key ?? 'displayTemplate', { stripLeadingGroup: true });
220
+ const groupFolder = path.join(basePath, groupPath);
221
+ const styleFolder = displayTemplateType === 'component' ? groupFolder : path.join(groupFolder, displayTemplateKeySlug);
222
+ if (createFolder && !fs.existsSync(styleFolder))
223
+ fs.mkdirSync(styleFolder, { recursive: true });
224
+ return {
225
+ styleFile: path.join(styleFolder, displayTemplateKeySlug + '.opti-style.json'),
226
+ helperFile: path.join(groupFolder, 'displayTemplates.ts'),
227
+ identifier: `${displayTemplateType}/${target}`,
228
+ helperFolder: groupFolder,
229
+ styleFolder
230
+ };
231
+ }
232
+ async function getStyleFilePaths(definition, opts) {
233
+ let defintionBaseType = undefined;
234
+ if (definition.contentType && !opts.contentBaseType && opts.client) {
235
+ const contentType = await opts.client.contentTypesGet({ path: { key: definition.contentType } }).catch(() => undefined);
236
+ defintionBaseType = contentType?.baseType;
237
+ }
238
+ else {
239
+ defintionBaseType = opts.contentBaseType;
240
+ }
241
+ return getStyleFilePathsSync(definition, defintionBaseType, opts.basePath, opts.createFolder);
242
+ }
254
243
  /**
244
+ * Simple logic to create path slugs for storing ContentType related files on
245
+ * disk.
255
246
  *
256
- * @param typeKey
247
+ * @param typeKey
248
+ * @param stripLeadingUnderscore
257
249
  * @returns
258
250
  */
259
- function typeToSlug(typeKey) {
260
- let typeKeySlug = typeKey.toLowerCase();
261
- if (typeKeySlug.startsWith('_'))
262
- typeKeySlug = typeKeySlug.substring(1);
263
- return typeKeySlug;
251
+ function keyToSlug(typeKey, options = {}) {
252
+ // Destruct the configuration into the parts we need
253
+ const { stripLeadingGroup = true, defaultKey = 'unknown', stripLeadingUnderscore = false, ...slugifyBaseConfig } = options;
254
+ const slugifyConfig = {
255
+ ...slugifyBaseConfig,
256
+ preserveLeadingUnderscore: !stripLeadingUnderscore
257
+ };
258
+ // First make sure we have a valid input type
259
+ if (!(typeof typeKey === 'string' || typeKey === undefined || typeKey === null))
260
+ throw new Error(`Invalid typeKey provided, expected an optional string, received a value of type ${typeof typeKey}`);
261
+ // Then, take the prefix out, if any and required
262
+ const toSlugify = stripLeadingGroup && typeKey?.indexOf(":") >= 0 ? typeKey.split(":", 2).at(1) : typeKey;
263
+ // Finally slugify the result
264
+ return slugify(toSlugify ?? defaultKey, slugifyConfig);
265
+ }
266
+ function getDisplayTemplateType(displayTemplate) {
267
+ const templateType = displayTemplate.baseType ? 'base' : displayTemplate.nodeType ? 'node' : displayTemplate.contentType ? 'component' : 'unknown';
268
+ return templateType;
269
+ }
270
+
271
+ /**
272
+ * Test if the value must be included in a set, by defining allowed values and blocked values. A value
273
+ * is considered be eligible to be included when it's both allowed an not disallowed.
274
+ *
275
+ * @param value The value to test
276
+ * @param allow The list of allowed values, when not provided, or and empty array, all
277
+ * values will be allowed.
278
+ * @param disallow The list of explicitly disallowed values, yielding an "all allowed but
279
+ * these" operation
280
+ * @param compareSlugified When set to `true`, and after test with `includes()` on the allow and
281
+ * disallow did not yield a match, will try to match by converting the
282
+ * values to string (using the `.toString()` method), then slugify them with
283
+ * the `keyToSlug` method and then comparing the outcomes.
284
+ * @param slugifyOptions The options to provide to the `keyToSlug` method, this will be ignored
285
+ * unless you set the `compareSlugified` parameter to `true`
286
+ * @returns If the value should be included in the result, based upon the `allow` and
287
+ * `disallow` configuration.
288
+ *
289
+ * @see {@link keyToSlug}
290
+ */
291
+ function shouldInclude(value, allow, disallow, compareSlugified = false, slugifyOptions) {
292
+ // Do not include null or undefined values
293
+ if (value === null || value === undefined)
294
+ return false;
295
+ // Item is allowed when either allow is not set, an empty array or has the value
296
+ const isAllowed = !Array.isArray(allow) || allow.length === 0 || allow.includes(value) || (compareSlugified && allow.some(av => keyToSlug(av.toString(), slugifyOptions) === keyToSlug(value.toString(), slugifyOptions)));
297
+ // Item is disallowed when and the array is set and includes the value
298
+ const isDisallowed = Array.isArray(disallow) && (disallow.includes(value) || (compareSlugified && disallow.some(av => keyToSlug(av.toString(), slugifyOptions) === keyToSlug(value.toString(), slugifyOptions))));
299
+ // Return the outcome
300
+ return isAllowed && !isDisallowed;
301
+ }
302
+ /**
303
+ * Test if the provided content type is a contract
304
+ *
305
+ * @param contentType The ContentType to test
306
+ * @returns `true` if the ContentType is a contract, `false` otherwise
307
+ * @see {@link IntegrationApi.ContentType}
308
+ */
309
+ function isContract(contentType) {
310
+ if (typeof contentType !== 'object' || contentType === null)
311
+ return false;
312
+ return contentType.isContract || contentType.source === 'globalcontract';
313
+ }
314
+ /**
315
+ * Test if the provided content type comes from the Graph content source (e.g. external content)
316
+ *
317
+ * @param contentType The ContentType to test
318
+ * @returns `true` if the ContentType is a graph type, `false` otherwise
319
+ * @see {@link IntegrationApi.ContentType}
320
+ */
321
+ function isGraphType(contentType) {
322
+ if (typeof contentType !== 'object' || contentType === null)
323
+ return false;
324
+ return (contentType.key && typeof (contentType.key) === 'string' && contentType.key.startsWith('graph:')) || contentType.source === 'graph';
325
+ }
326
+ /**
327
+ * Test if the provided content type is a folder, which is a structural element for editors, but
328
+ * does not represent anything significant when working with ContentType
329
+ *
330
+ * @param contentType The ContentType to test
331
+ * @returns `true` if the ContentType is a folder, `false` otherwise
332
+ * @see {@link IntegrationApi.ContentType}
333
+ */
334
+ function isFolder(contentType) {
335
+ if (typeof contentType !== 'object' || contentType === null)
336
+ return false;
337
+ return contentType.baseType === '_folder';
338
+ }
339
+ /**
340
+ * Test if the provided content type is a system type
341
+ *
342
+ * @param contentType The ContentType to test
343
+ * @returns `true` if the ContentType is a folder, `false` otherwise
344
+ * @see {@link IntegrationApi.ContentType}
345
+ */
346
+ function isSystemType(contentType) {
347
+ if (typeof contentType !== 'object' || contentType === null)
348
+ return false;
349
+ return contentType.source === 'system';
350
+ }
351
+ function isNonEmptyArray(toTest) {
352
+ return Array.isArray(toTest) && toTest.length > 0;
353
+ }
354
+ function isEmptyArray(toTest) {
355
+ return toTest === null || !Array.isArray(toTest) || toTest.length === 0;
356
+ }
357
+ function isDefined(toTest) {
358
+ return toTest !== null && toTest !== undefined;
264
359
  }
265
360
 
266
361
  const ContentTypesArgsDefaults = {
@@ -283,28 +378,41 @@ async function getContentTypes(client, args, pageSize = 25, allowSystem = false,
283
378
  const allContentTypes = [];
284
379
  const filteredContentTypes = [];
285
380
  for await (const contentType of getAllContentTypes(client, cfg.debug, pageSize)) {
286
- // Skip content types mapped against Graph data, these should be used with their source type in Graph, not the reference in CMS
287
- if (!all && contentType.key.toLowerCase().startsWith('graph:')) {
381
+ // Skip contracts by default as they're abstract classes and cannot be used to directly store data.
382
+ if (!all && isContract(contentType)) {
383
+ if (cfg.debug)
384
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key || contentType.displayName || "unnamed type"} as it is a contract and cannot be instantiated directly, use --all to include\n`));
385
+ continue;
386
+ }
387
+ // Skip content types mapped against Graph data, these should be used with their source type in Graph,
388
+ // not the reference in CMS
389
+ if (!all && isGraphType(contentType)) {
390
+ if (cfg.debug)
391
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it is a reference to external data in Optimizely Graph, use --all to include\n`));
392
+ continue;
393
+ }
394
+ // Skip folder types as these are non-data carrying types in the instance.
395
+ if (!all && isFolder(contentType)) {
288
396
  if (cfg.debug)
289
- process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it is a reference to external data in Optimizely Graph\n`));
397
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it is a folder, use --all to include\n`));
290
398
  continue;
291
399
  }
292
400
  // Build the unfiltered array
293
401
  allContentTypes.push(contentType);
294
402
  // Skip based upon base type filters
295
- if (!shouldInclude(typeToSlug(contentType.baseType), baseTypes.map(x => typeToSlug(x)), excludeBaseTypes.map(x => typeToSlug(x)))) {
403
+ if (!shouldInclude(contentType.baseType, baseTypes, excludeBaseTypes, true)) {
296
404
  if (cfg.debug)
297
405
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it has a restricted base type: ${contentType.baseType}\n`));
298
406
  continue;
299
407
  }
300
408
  // Skip based upon type filters
301
- if (!shouldInclude(contentType.key, types, excludeTypes)) {
409
+ if (!shouldInclude(contentType.key, types, excludeTypes, true)) {
302
410
  if (cfg.debug)
303
- process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it has a restricted type: ${contentType.key}\n`));
411
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it is a restricted type (${types.join(', ')})(${excludeTypes.join(', ')})\n`));
304
412
  continue;
305
413
  }
306
414
  // Skip based upon system filter
307
- if (contentType.source == 'system' && !allowSystem) {
415
+ if (!allowSystem && isSystemType(contentType)) {
308
416
  if (cfg.debug)
309
417
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Content-Type ${contentType.key} due to it being a system type\n`));
310
418
  continue;
@@ -322,13 +430,6 @@ async function getContentTypes(client, args, pageSize = 25, allowSystem = false,
322
430
  process.stdout.write(chalk.gray(`${figures.arrowRight} Applied content type filters, reduced from ${allContentTypes.length} to ${filteredContentTypes.length} items\n`));
323
431
  return { all: allContentTypes, contentTypes: filteredContentTypes };
324
432
  }
325
- function shouldInclude(value, allow, disallow) {
326
- // Item is allowed when either allow is not set, an empty array or has the value
327
- const isAllowed = !Array.isArray(allow) || allow.length === 0 || allow.includes(value);
328
- // Item is disallowed when and the array is set and includes the value
329
- const isDisallowed = Array.isArray(disallow) && disallow.includes(value);
330
- return isAllowed && !isDisallowed;
331
- }
332
433
  /**
333
434
  * Retrieve all content types as an Async Generator, allowing processing of entries whilest they are being loaded from the CMS instance.
334
435
  *
@@ -376,50 +477,60 @@ const stylesBuilder = yargs => {
376
477
  return newArgs;
377
478
  };
378
479
  async function getStyles(client, args, pageSize = 25) {
379
- if (client.runtimeCmsVersion == OptiCmsVersion.CMS12)
380
- return { all: [], styles: [] };
381
- const { _config: cfg, excludeBaseTypes, excludeTypes, excludeNodeTypes, excludeTemplates, baseTypes, types, nodes, templates, templateTypes } = parseArgs(args);
480
+ const { _config: cfg, excludeBaseTypes: disallowBaseTypes, excludeTypes: disallowTypes, excludeNodeTypes: disallowNodeTypes, excludeTemplates: disallowTemplates, baseTypes: allowBaseTypes, types: allowTypes, nodes: allowNodeTypes, templates: allowTemplates, templateTypes: baseAllowTemplateTypes } = parseArgs(args);
382
481
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Pulling Style-Definitions from Optimizely CMS\n`));
482
+ const allowTemplateTypes = [];
483
+ if (!Array.isArray(baseAllowTemplateTypes) || baseAllowTemplateTypes.length === 0) {
484
+ if (isNonEmptyArray(allowBaseTypes)) {
485
+ console.log('BT', allowBaseTypes);
486
+ if (cfg.debug)
487
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Base picking filter is active, adjusting template type filter to pick templates targeting a base type\n`));
488
+ allowTemplateTypes.push('base');
489
+ }
490
+ if (isNonEmptyArray(allowNodeTypes)) {
491
+ if (cfg.debug)
492
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Node type filter active, adjusting template type filter\n`));
493
+ allowTemplateTypes.push('node');
494
+ }
495
+ if (isNonEmptyArray(allowTypes)) {
496
+ if (cfg.debug)
497
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Component type filter active, adjusting template type filter\n`));
498
+ allowTemplateTypes.push('component');
499
+ }
500
+ }
501
+ else {
502
+ allowTemplateTypes.push(...baseAllowTemplateTypes);
503
+ }
383
504
  const allDisplayTemplates = [];
384
505
  const filteredDisplayTemplates = [];
385
506
  for await (const displayTemplate of getAllStyles(client, cfg.debug, pageSize)) {
386
507
  allDisplayTemplates.push(displayTemplate);
387
- const templateType = displayTemplate.baseType ? 'base' : displayTemplate.nodeType ? 'node' : displayTemplate.contentType ? 'component' : 'unknown';
388
- if (isExcluded(displayTemplate.key, excludeTemplates, templates)) {
508
+ const templateType = getDisplayTemplateType(displayTemplate);
509
+ if (!shouldInclude(displayTemplate.key, allowTemplates, disallowTemplates)) {
389
510
  if (cfg.debug)
390
511
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Style-Defintion ${displayTemplate.key} - Style defintion key filtering active\n`));
391
512
  continue;
392
513
  }
393
- if (isExcluded(templateType, [], templateTypes)) {
514
+ if (!shouldInclude(templateType, allowTemplateTypes)) {
394
515
  if (cfg.debug)
395
516
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Style-Defintion ${displayTemplate.key} - Style type filtering is active\n`));
396
517
  continue;
397
518
  }
398
- if (displayTemplate.baseType && isExcluded(displayTemplate.baseType, excludeBaseTypes, baseTypes)) {
519
+ if (templateType === 'base' && !shouldInclude(displayTemplate.baseType, allowBaseTypes, disallowBaseTypes)) {
399
520
  if (cfg.debug)
400
521
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Style-Defintion ${displayTemplate.key} - Style is defined at base type level and base type filtering is active\n`));
401
522
  continue;
402
523
  }
403
- if (displayTemplate.contentType && isExcluded(displayTemplate.contentType, excludeTypes, types)) {
524
+ if (templateType === 'component' && !shouldInclude(displayTemplate.contentType, allowTypes, disallowTypes)) {
404
525
  if (cfg.debug)
405
526
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Style-Defintion ${displayTemplate.key} - Style is defined at component type level and component type filtering is active\n`));
406
527
  continue;
407
528
  }
408
- if (templateType != 'component' && types.length > 0) {
409
- if (cfg.debug)
410
- process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Style-Defintion ${displayTemplate.key} - Style is targeting the ${templateType} level and component type selection is active\n`));
411
- continue;
412
- }
413
- if (displayTemplate.nodeType && isExcluded(displayTemplate.nodeType, excludeNodeTypes, nodes)) {
529
+ if (templateType === 'node' && !shouldInclude(displayTemplate.nodeType, allowNodeTypes, disallowNodeTypes)) {
414
530
  if (cfg.debug)
415
531
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Style-Defintion ${displayTemplate.key} - Style is defined at node type level and node type filtering is active\n`));
416
532
  continue;
417
533
  }
418
- if (templateType != 'node' && nodes.length > 0) {
419
- if (cfg.debug)
420
- process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Style-Defintion ${displayTemplate.key} - Style is targeting the ${templateType} level and node type selection is active\n`));
421
- continue;
422
- }
423
534
  filteredDisplayTemplates.push(displayTemplate);
424
535
  }
425
536
  if (cfg.debug)
@@ -430,8 +541,6 @@ async function getStyles(client, args, pageSize = 25) {
430
541
  };
431
542
  }
432
543
  async function* getAllStyles(client, debug = false, pageSize = 5) {
433
- if (client.runtimeCmsVersion == OptiCmsVersion.CMS12)
434
- return;
435
544
  let requestPageSize = pageSize;
436
545
  let requestPageIndex = 0;
437
546
  let totalItemCount = 0;
@@ -459,26 +568,268 @@ async function* getAllStyles(client, debug = false, pageSize = 5) {
459
568
  }
460
569
  } while (requestPageIndex < totalPages);
461
570
  }
462
- function isExcluded(value, exclusions, inclusions) {
463
- if (value == undefined || value == null)
464
- return false;
465
- return exclusions.includes(value) || (inclusions.length > 0 && !inclusions.includes(value));
466
- }
467
- async function getStyleFilePath(definition, opts) {
468
- if (definition.nodeType)
469
- return `nodes/${definition.nodeType}/${definition.key}/${definition.key}.opti-style.json`;
470
- if (definition.baseType)
471
- return `${definition.baseType}/styles/${definition.key}/${definition.key}.opti-style.json`;
472
- if (definition.contentType) {
473
- if (opts?.contentBaseType)
474
- return `${opts?.contentBaseType}/${definition.contentType}/${definition.key}.opti-style.json`;
475
- if (!opts?.client)
476
- throw new Error("Neither the contentBaseType, nor the ApiClient has been provided for a definition for a specific ContentType - unable to generate the path");
477
- const contentType = await opts.client.contentTypesGet({ path: { key: definition.contentType } }).catch(() => undefined);
478
- if (contentType)
479
- return `${contentType.baseType}/${definition.contentType}/${definition.key}.opti-style.json`;
571
+ class TypeFilesList extends Map {
572
+ getDisplayTemplateByKey(displayTemplateKey) {
573
+ for (const groupKey of this.keys()) {
574
+ const templates = this.get(groupKey)?.templates || [];
575
+ const displayTemplate = templates.find(x => x.key === displayTemplateKey);
576
+ if (displayTemplate)
577
+ return displayTemplate.data;
578
+ }
579
+ return undefined;
580
+ }
581
+ getDisplayTemplatePathsByKey(displayTemplateKey) {
582
+ for (const identifier of this.keys()) {
583
+ const groupInfo = this.get(identifier);
584
+ const templates = groupInfo?.templates || [];
585
+ const helperFile = groupInfo?.filePath;
586
+ const helperFolder = groupInfo?.fileFolder;
587
+ const info = templates.find(x => x.key === displayTemplateKey);
588
+ if (info)
589
+ return {
590
+ styleFile: info.file,
591
+ styleFolder: info.folder,
592
+ identifier,
593
+ helperFile,
594
+ helperFolder
595
+ };
596
+ }
597
+ return undefined;
598
+ }
599
+ }
600
+ async function toTypeFilesList(displayTemplates, client, basePath) {
601
+ if (isNonEmptyArray(displayTemplates)) {
602
+ // Get all content types we need to get the type file lists
603
+ const targetContentTypes = (await Promise.allSettled(displayTemplates.map(async (displayTemplate) => {
604
+ if (!displayTemplate.contentType)
605
+ return undefined;
606
+ return client.contentTypesGet({ path: { key: displayTemplate.contentType } });
607
+ }))).map(x => x.status == 'fulfilled' ? x.value : undefined).filter(isDefined);
608
+ // Simple helper to get the base type from the list
609
+ function getBaseTypeOf(contentTypeKey) {
610
+ if (!contentTypeKey)
611
+ return undefined;
612
+ return targetContentTypes.find(x => x.key === contentTypeKey)?.baseType;
613
+ }
614
+ // Now reduce the list into the Map we need
615
+ return displayTemplates.reduce((aggregator, displayTemplate) => {
616
+ const contentTypeBaseType = getBaseTypeOf(displayTemplate.contentType);
617
+ const { identifier, helperFile, helperFolder, styleFile, styleFolder } = getStyleFilePathsSync(displayTemplate, contentTypeBaseType, basePath, true);
618
+ const info = aggregator.get(identifier) ?? { filePath: helperFile, fileFolder: helperFolder, templates: [] };
619
+ info.templates.push({ key: displayTemplate.key, file: styleFile, data: displayTemplate, folder: styleFolder });
620
+ aggregator.set(identifier, info);
621
+ return aggregator;
622
+ }, new TypeFilesList());
623
+ }
624
+ return new TypeFilesList();
625
+ }
626
+
627
+ function normalizeMergePatch(value, omitKeys, requiredKeys = [], requiredSource) {
628
+ if (value === undefined || value === null)
629
+ return null;
630
+ if (Array.isArray(value))
631
+ return value.map(item => normalizeMergePatch(item, omitKeys));
632
+ if (typeof value !== 'object')
633
+ return value;
634
+ const output = Object.entries(value).reduce((acc, [key, entry]) => {
635
+ if (!omitKeys.includes(key))
636
+ acc[key] = normalizeMergePatch(entry, omitKeys);
637
+ return acc;
638
+ }, {});
639
+ const withRequiredKeys = requiredKeys.reduce((acc, key) => {
640
+ if (requiredSource && !Object.keys(acc).includes(key))
641
+ acc[key] = requiredSource[key];
642
+ return acc;
643
+ }, output);
644
+ return withRequiredKeys;
645
+ }
646
+ /**
647
+ * Computes a JSON Merge Patch (RFC 7396) that transforms `currentValue` into `newValue`.
648
+ *
649
+ * The resulting patch can be sent directly as the request body of an `application/merge-patch+json`
650
+ * request. Changed and added properties are included with their new value; removed properties are
651
+ * included with a `null` value (as required by RFC 7396); unchanged properties are omitted entirely.
652
+ *
653
+ * @see https://datatracker.ietf.org/doc/html/rfc7396
654
+ *
655
+ * @example Basic usage
656
+ * ```ts
657
+ * const current = { name: 'Alice', age: 30, role: 'user' }
658
+ * const updated = { name: 'Alice', age: 31 }
659
+ *
660
+ * generatePatch(current, updated)
661
+ * // => { age: 31, role: null }
662
+ * // `age` is replaced, `role` is removed (null), `name` is unchanged (omitted)
663
+ * ```
664
+ *
665
+ * @example Excluding keys from the patch
666
+ * ```ts
667
+ * const current = { id: '123', name: 'Alice', version: 1 }
668
+ * const updated = { id: '123', name: 'Bob', version: 2 }
669
+ *
670
+ * generatePatch(current, updated, { readonlyFields: ['id', 'version'] })
671
+ * // => { name: 'Bob' }
672
+ * // `id` and `version` are excluded even though they are present in the diff
673
+ * ```
674
+ *
675
+ * @example Always include required fields
676
+ * ```ts
677
+ * const current = { id: '123', name: 'Alice', version: 1 }
678
+ * const updated = { id: '123', name: 'Alice', version: 2 }
679
+ *
680
+ * generatePatch(current, updated, { requiredFields: ['id'] })
681
+ * // => { id: '123', version: 2 }
682
+ * // `id` is included even though it did not change
683
+ * ```
684
+ *
685
+ * @example Nested objects
686
+ * ```ts
687
+ * const current = { address: { city: 'Amsterdam', zip: '1000AA' } }
688
+ * const updated = { address: { city: 'Utrecht' } }
689
+ *
690
+ * generatePatch(current, updated)
691
+ * // => { address: { city: 'Utrecht', zip: null } }
692
+ * ```
693
+ *
694
+ * @param currentValue - The current state of the resource.
695
+ * @param newValue - The desired state of the resource.
696
+ * @param options - Optional configuration.
697
+ * @param options.readonlyFields - Keys that should never appear in the generated patch,
698
+ * regardless of whether they changed.
699
+ * @param options.requiredFields - Keys that should always appear in the generated patch,
700
+ * using values from `newValue`.
701
+ * @returns A {@link MergePatch} object ready to be serialised as an `application/merge-patch+json` body.
702
+ */
703
+ function generatePatch(currentValue, newValue, options = {}) {
704
+ const requiredKeys = options.requiredFields ?? [];
705
+ const omitKeys = (options.readonlyFields ?? []).filter(key => !requiredKeys.includes(key));
706
+ const patch = diff(currentValue, newValue);
707
+ return normalizeMergePatch(patch, omitKeys, requiredKeys, newValue);
708
+ }
709
+ /**
710
+ * Extracts all field paths from a {@link MergePatch} as dot-separated strings.
711
+ * Nested objects are flattened recursively; leaf values (including `null`) produce a path entry.
712
+ *
713
+ * @example
714
+ * ```ts
715
+ * getPatchFields({ a: { b: 0 }, c: 'value' })
716
+ * // => ['a.b', 'c']
717
+ * ```
718
+ *
719
+ * @param patch - The merge patch to extract fields from.
720
+ * @param prefix - Internal prefix used during recursion; omit when calling directly.
721
+ * @returns An array of dot-separated field path strings.
722
+ */
723
+ function getPatchFields(patch, prefix = '') {
724
+ return Object.entries(patch).flatMap(([key, value]) => {
725
+ const path = prefix ? `${prefix}.${key}` : key;
726
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
727
+ ? getPatchFields(value, path)
728
+ : [path];
729
+ });
730
+ }
731
+
732
+ const StylesPushCommand = {
733
+ command: "styles:push",
734
+ describe: "Push Visual Builder style definitions into the CMS (create/replace)",
735
+ builder: (yargs) => {
736
+ yargs.option('excludeTemplates', { alias: 'e', description: "Exclude these templates", string: true, type: 'array', demandOption: false, default: [] });
737
+ yargs.option("templates", { alias: 't', description: "Select only these templates", string: true, type: 'array', demandOption: false, default: [] });
738
+ return yargs;
739
+ },
740
+ handler: async (args) => {
741
+ const { _config: cfg, excludeTemplates, templates, ...opts } = parseArgs(args);
742
+ const client = createCmsClient(args);
743
+ const { styles: displayTemplates } = await getStyles(client, {
744
+ all: false,
745
+ baseTypes: [],
746
+ excludeBaseTypes: [],
747
+ excludeNodeTypes: [],
748
+ excludeTemplates: [],
749
+ excludeTypes: [],
750
+ nodes: [],
751
+ templates: [],
752
+ templateTypes: [],
753
+ types: [],
754
+ ...args
755
+ }, 50);
756
+ process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Pushing (create/replace) DisplayStyles into Optimizely CMS\n`));
757
+ const styleDefinitionFiles = await glob("./**/*.opti-style.json", {
758
+ cwd: opts.components
759
+ });
760
+ const results = (await Promise.allSettled(styleDefinitionFiles.map(async (styleDefinitionFile) => {
761
+ const filePath = path.normalize(path.join(opts.components, styleDefinitionFile));
762
+ const styleDefinition = tryReadJsonFile(filePath, cfg.debug);
763
+ const styleKey = styleDefinition.key;
764
+ if (!styleKey) {
765
+ process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} The style definition in ${path.relative(opts.path, filePath)} does not have a key defined\n`));
766
+ return undefined;
767
+ }
768
+ if (excludeTemplates.includes(styleKey))
769
+ return undefined; // Skip excluded styles
770
+ if (templates.length > 0 && !templates.includes(styleKey))
771
+ return undefined; // Only include defined styles, if any
772
+ if (cfg.debug)
773
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Pushing: ${styleKey}\n`));
774
+ // Try to fetch the current template
775
+ const currentTemplate = displayTemplates.find(dt => dt.key === styleKey);
776
+ // Create / Replace the current template
777
+ const newTemplate = await (currentTemplate ? (async () => {
778
+ const patch = generatePatch(currentTemplate, styleDefinition, {
779
+ readonlyFields: ['key', 'created', 'lastModified', 'createdBy', 'lastModifiedBy']
780
+ });
781
+ if (!path || Object.entries(patch).length === 0)
782
+ return currentTemplate;
783
+ // @ts-expect-error There's a mis-match between the logic in the CMS and the contents of the
784
+ // OpenAPI Spec file.
785
+ return client.displayTemplatesPatch({ path: { key: styleKey }, body: patch });
786
+ })() :
787
+ client.displayTemplatesCreate({ body: styleDefinition }));
788
+ const missedFields = generatePatch(newTemplate, currentTemplate, {
789
+ readonlyFields: ['key', 'created', 'lastModified', 'createdBy', 'lastModifiedBy']
790
+ });
791
+ if (missedFields && Object.keys(missedFields).length > 0)
792
+ throw new Error(`The Display template ${styleKey} failed to update properties: ${getPatchFields(missedFields).join('; ')}`);
793
+ return newTemplate;
794
+ })));
795
+ const styles = new Table({
796
+ head: [
797
+ chalk.yellow(chalk.bold("Name")),
798
+ chalk.yellow(chalk.bold("Key")),
799
+ chalk.yellow(chalk.bold("Default")),
800
+ chalk.yellow(chalk.bold("Target"))
801
+ ],
802
+ colWidths: [31, 20, 9, 20],
803
+ colAligns: ["left", "left", "center", "left"]
804
+ });
805
+ results.forEach(result => {
806
+ if (result.status === 'fulfilled') {
807
+ const tpl = result.value;
808
+ styles.push([
809
+ tpl.displayName,
810
+ tpl.key,
811
+ tpl.isDefault ? figures.tick : figures.cross,
812
+ tpl.contentType ? `${tpl.contentType} (C)` : tpl.baseType ? `${tpl.baseType} (B)` : `${tpl.nodeType} (N)`
813
+ ]);
814
+ }
815
+ else {
816
+ process.stderr.write(`Error processing DisplayTemplate: ${result.reason}\n`);
817
+ }
818
+ });
819
+ process.stdout.write(styles.toString() + "\n");
820
+ process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
821
+ }
822
+ };
823
+ function tryReadJsonFile(filePath, debug = false) {
824
+ try {
825
+ if (debug)
826
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Reading style definition from ${filePath}\n`));
827
+ return JSON.parse(fs.readFileSync(filePath, { encoding: 'utf-8' }));
828
+ }
829
+ catch (e) {
830
+ process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} Error while reading ${filePath}\n`));
480
831
  }
481
- throw new Error(`Unable to resolve the target for the DisplayTemplate: ${definition.key}`);
832
+ return undefined;
482
833
  }
483
834
 
484
835
  const StylesListCommand = {
@@ -514,6 +865,14 @@ const StylesListCommand = {
514
865
  }
515
866
  };
516
867
 
868
+ function ucFirst(current) {
869
+ if (typeof current != 'string')
870
+ throw new Error("Only strings can be transformed");
871
+ if (current == "")
872
+ return current;
873
+ return current[0]?.toUpperCase() + current.substring(1);
874
+ }
875
+
517
876
  const StylesPullCommand = {
518
877
  command: "styles:pull",
519
878
  describe: "Create Visual Builder style definitions from the CMS",
@@ -524,124 +883,59 @@ const StylesPullCommand = {
524
883
  return newYargs;
525
884
  },
526
885
  handler: async (args) => {
527
- const { _config: cfg, components: basePath, force, definitions } = parseArgs(args);
886
+ const { _config: cfg, components: basePath, force} = parseArgs(args);
528
887
  const client = createCmsClient(args);
529
- if (client.runtimeCmsVersion == OptiCmsVersion.CMS12) {
530
- process.stdout.write(chalk.gray(`${figures.cross} Styles are not supported on CMS12\n`));
531
- return;
532
- }
533
888
  const { styles: filteredResults } = await getStyles(client, args);
534
- //#region Create & Write opti-style.json files
535
889
  process.stdout.write(chalk.gray(`${figures.arrowRight} Start creating .opti-style.json files\n`));
536
- const typeFiles = {};
537
- const updatedTemplates = (await Promise.all(filteredResults.map(async (displayTemplate) => {
538
- // Create metadata
539
- const { targetType, styleFilePath: filePath, helperFilePath: helperPath } = await createTemplateMetadata(client, displayTemplate, basePath, true);
540
- // Build local style data
541
- const outputTemplate = { ...displayTemplate };
542
- if (outputTemplate.createdBy)
543
- delete outputTemplate.createdBy;
544
- if (outputTemplate.lastModifiedBy)
545
- delete outputTemplate.lastModifiedBy;
546
- if (outputTemplate.created)
547
- delete outputTemplate.created;
548
- if (outputTemplate.lastModified)
549
- delete outputTemplate.lastModified;
550
- // Write file to disk
551
- if (fs.existsSync(filePath)) {
552
- if (!force) {
553
- if (cfg.debug)
554
- process.stdout.write(chalk.gray(`${figures.cross} Skipping style file for ${displayTemplate.key} - File already exists\n`));
555
- }
556
- else {
557
- if (cfg.debug) {
558
- process.stdout.write(chalk.gray(`${figures.arrowRight} Overwriting style file for ${displayTemplate.key}\n`));
559
- }
560
- fs.writeFileSync(filePath, JSON.stringify(outputTemplate, undefined, 2));
561
- }
890
+ const styleFiles = await toTypeFilesList(filteredResults, client, basePath);
891
+ const updatedTemplates = [];
892
+ for (const groupIdentifier of styleFiles.keys()) {
893
+ const displayTemplateGroup = styleFiles.get(groupIdentifier);
894
+ for (const { file: filePath, data: displayTemplate } of (displayTemplateGroup?.templates || [])) {
895
+ // Write JSON to disk
896
+ var updatedJson = await createDisplayTemplateFile(displayTemplate, filePath, force, cfg.debug);
897
+ if (updatedJson)
898
+ updatedTemplates.push(displayTemplate.key);
562
899
  }
563
- else {
564
- if (cfg.debug)
565
- process.stdout.write(chalk.gray(`${figures.arrowRight} Creating style file for ${displayTemplate.key} in ${filePath}\n`));
566
- fs.writeFileSync(filePath, JSON.stringify(outputTemplate, undefined, 2));
567
- }
568
- // Ensure we're tracking all files
569
- if (!typeFiles[targetType]) {
570
- typeFiles[targetType] = {
571
- filePath: helperPath,
572
- templates: []
573
- };
574
- }
575
- typeFiles[targetType].templates.push({ key: displayTemplate.key, file: filePath, data: displayTemplate });
576
- return displayTemplate.key;
577
- }))).filter(x => x);
578
- //#endregion
579
- //#region Create needed definition files
580
- if (definitions)
581
- await createDisplayTemplateHelpers(typeFiles);
582
- //#endregion
900
+ // Write template to disk
901
+ void await createDisplayTemplateHelper(displayTemplateGroup, groupIdentifier, force, cfg.debug);
902
+ }
583
903
  process.stdout.write(chalk.green(chalk.bold(figures.tick + ` Created/updated style definitions for ${updatedTemplates.join(', ')}`)) + "\n");
584
904
  }
585
905
  };
586
- function ucFirst$1(input) {
587
- if (typeof (input) != 'string' || input.length < 1)
588
- return input;
589
- return input[0].toUpperCase() + input.substring(1);
590
- }
591
- async function createTemplateMetadata(client, displayTemplate, basePath, createPaths = true) {
592
- let itemPath = undefined;
593
- let targetType;
594
- let typesPath;
595
- const targetPrefix = getTemplateTarget(displayTemplate);
596
- switch (targetPrefix) {
597
- case 'node':
598
- itemPath = path.join(basePath, 'nodes', displayTemplate.nodeType, displayTemplate.key);
599
- typesPath = path.join(basePath, 'nodes', displayTemplate.nodeType);
600
- targetType = targetPrefix + '/' + displayTemplate.nodeType;
601
- break;
602
- case 'base': {
603
- const baseTypeSlug = baseTypeToSlug(displayTemplate.baseType);
604
- itemPath = path.join(basePath, baseTypeSlug, 'styles', displayTemplate.key);
605
- typesPath = path.join(basePath, baseTypeSlug, 'styles');
606
- targetType = targetPrefix + '/' + baseTypeSlug;
607
- break;
906
+ async function createDisplayTemplateFile(displayTemplate, filePath, force = false, debug = false) {
907
+ let updated = false;
908
+ // Build local style data
909
+ const outputTemplate = { ...displayTemplate };
910
+ if (outputTemplate.createdBy)
911
+ delete outputTemplate.createdBy;
912
+ if (outputTemplate.lastModifiedBy)
913
+ delete outputTemplate.lastModifiedBy;
914
+ if (outputTemplate.created)
915
+ delete outputTemplate.created;
916
+ if (outputTemplate.lastModified)
917
+ delete outputTemplate.lastModified;
918
+ // Write file to disk
919
+ if (fs.existsSync(filePath)) {
920
+ if (!force) {
921
+ if (debug)
922
+ process.stdout.write(chalk.gray(`${figures.cross} Skipping style file for ${displayTemplate.key} - File already exists\n`));
608
923
  }
609
- case 'content': {
610
- const contentType = await client.contentTypesGet({ path: { key: displayTemplate.contentType ?? '-' } });
611
- const baseTypeSlug = baseTypeToSlug(contentType.baseType);
612
- itemPath = path.join(basePath, baseTypeSlug, contentType.key);
613
- typesPath = path.join(basePath, baseTypeSlug, contentType.key);
614
- targetType = targetPrefix + '/' + displayTemplate.contentType;
615
- break;
924
+ else {
925
+ if (debug) {
926
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Overwriting style file for ${displayTemplate.key}\n`));
927
+ }
928
+ fs.writeFileSync(filePath, JSON.stringify(outputTemplate, undefined, 2));
929
+ updated = true;
616
930
  }
617
- default:
618
- throw new Error("Unsupported display template type");
619
931
  }
620
- if (createPaths)
621
- void await fsAsync.mkdir(itemPath, { recursive: true }).catch(e => {
622
- console.log(e);
623
- });
624
- const styleFilePath = path.join(itemPath, `${displayTemplate.key}.opti-style.json`);
625
- const helperFilePath = path.join(typesPath, 'displayTemplates.ts');
626
- return { key: displayTemplate.key, itemPath, typesPath, targetType, styleFilePath, helperFilePath };
627
- }
628
- function baseTypeToSlug(baseType) {
629
- return baseType.replace(/^\_*/, "").toLowerCase();
630
- }
631
- function getTemplateTarget(displayTemplate) {
632
- if (displayTemplate.baseType && displayTemplate.baseType.length > 0)
633
- return 'base';
634
- if (displayTemplate.nodeType && displayTemplate.nodeType.length > 0)
635
- return 'node';
636
- if (displayTemplate.contentType && displayTemplate.contentType.length > 0)
637
- return 'content';
638
- return null;
639
- }
640
- async function createDisplayTemplateHelpers(typeFiles, force = false, debug = false) {
641
- process.stdout.write(chalk.gray(`${figures.arrowRight} Start creating displayTemplates.ts files\n`));
642
- for (const targetId in typeFiles) {
643
- await createDisplayTemplateHelper(typeFiles[targetId], targetId, force, debug);
932
+ else {
933
+ if (debug)
934
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Creating style file for ${displayTemplate.key} in ${filePath}\n`));
935
+ fs.writeFileSync(filePath, JSON.stringify(outputTemplate, undefined, 2));
936
+ updated = true;
644
937
  }
938
+ return updated;
645
939
  }
646
940
  async function createDisplayTemplateHelper(typeFile, typeFileId, force = false, debug = false) {
647
941
  const prefix = '//not-modified - Remove this line when making change to prevent it from being updated by the CLI tools';
@@ -680,20 +974,20 @@ async function createDisplayTemplateHelper(typeFile, typeFileId, force = false,
680
974
  typeContents.push(`export type ${displayTemplate.key}Props = LayoutProps<typeof ${displayTemplate.key}Styles>`);
681
975
  typeContents.push(`export type ${displayTemplate.key}Keys = LayoutPropsSettingKeys<${displayTemplate.key}Props>`);
682
976
  typeContents.push(`export type ${displayTemplate.key}Options<K extends ${displayTemplate.key}Keys> = LayoutPropsSettingValues<${displayTemplate.key}Props, K>`);
683
- typeContents.push(`export type ${displayTemplate.key}ComponentProps<DT extends Record<string, any> = Record<string, any>> = Omit<CmsComponentProps<DT, ${displayTemplate.key}Props>,'children'> & JSX.IntrinsicElements['div']`);
684
- typeContents.push(`export type ${displayTemplate.key}Component<DT extends Record<string, any> = Record<string, any>> = ComponentType<${displayTemplate.key}ComponentProps<DT>>`);
977
+ typeContents.push(`export type ${displayTemplate.key}ComponentProps<DT extends Record<string, unknown> = Record<string, unknown>> = Omit<CmsComponentProps<DT, ${displayTemplate.key}Props>,'children'> & JSX.IntrinsicElements['div']`);
978
+ typeContents.push(`export type ${displayTemplate.key}Component<DT extends Record<string, unknown> = Record<string, unknown>> = ComponentType<${displayTemplate.key}ComponentProps<DT>>`);
685
979
  typeContents.push('');
686
980
  props.push(`${displayTemplate.key}Props`);
687
981
  if (!typeId)
688
982
  typeId = displayTemplate.nodeType ?? displayTemplate.baseType ?? displayTemplate.contentType;
689
983
  });
690
984
  if (typeId) {
691
- typeId = ucFirst$1(typeId);
985
+ typeId = ucFirst(typeId);
692
986
  typeContents.push(`export type ${typeId}LayoutProps = ${props.join(' | ')}
693
987
  export type ${typeId}LayoutKeys = LayoutPropsSettingKeys<${typeId}LayoutProps>
694
988
  export type ${typeId}LayoutOptions<K extends ${typeId}LayoutKeys> = LayoutPropsSettingValues<${typeId}LayoutProps,K>
695
- export type ${typeId}ComponentProps<DT extends Record<string, any> = Record<string, any>> = Omit<CmsComponentProps<DT, ${typeId}LayoutProps>,'children'> & JSX.IntrinsicElements['div']
696
- export type ${typeId}Component<DT extends Record<string, any> = Record<string, any>> = ComponentType<${typeId}ComponentProps<DT>>`);
989
+ export type ${typeId}ComponentProps<DT extends Record<string, unknown> = Record<string, unknown>> = Omit<CmsComponentProps<DT, ${typeId}LayoutProps>,'children'> & JSX.IntrinsicElements['div']
990
+ export type ${typeId}Component<DT extends Record<string, unknown> = Record<string, unknown>> = ComponentType<${typeId}ComponentProps<DT>>`);
697
991
  const defaultTemplate = templates.find(t => t.data.isDefault);
698
992
  if (defaultTemplate) {
699
993
  typeContents.push(`
@@ -724,10 +1018,6 @@ const StylesCreateCommand = {
724
1018
  handler: async (args) => {
725
1019
  const { components: basePath } = parseArgs(args);
726
1020
  const client = createCmsClient(args);
727
- if (client.runtimeCmsVersion == OptiCmsVersion.CMS12) {
728
- process.stdout.write(chalk.gray(`${figures.cross} Styles are not supported on CMS12\n`));
729
- return;
730
- }
731
1021
  const allowedBaseTypes = ['section', 'component', 'experience'];
732
1022
  // Prepare
733
1023
  process.stdout.write(chalk.yellowBright(chalk.bold(`Reading current information from Optimizely CMS\n`)));
@@ -776,10 +1066,10 @@ const StylesCreateCommand = {
776
1066
  definition[type] = typeId;
777
1067
  definition['settings'] = {};
778
1068
  const contentBaseType = type == "contentType" ? contentTypes.filter(x => x.key == typeId).map(x => x.baseType).at(0) : undefined;
779
- const styleFilePath = await getStyleFilePath(definition, { contentBaseType, client });
780
- if (!fs.existsSync(path.join(basePath, path.dirname(styleFilePath))))
781
- fs.mkdirSync(path.join(basePath, path.dirname(styleFilePath)), { recursive: true });
782
- if (fs.existsSync(path.join(basePath, styleFilePath))) {
1069
+ const { styleFile: styleFilePath, styleFolder: styleFileFolder } = await getStyleFilePaths(definition, { contentBaseType, client, basePath });
1070
+ if (!fs.existsSync(styleFileFolder))
1071
+ fs.mkdirSync(styleFileFolder, { recursive: true });
1072
+ if (fs.existsSync(styleFilePath)) {
783
1073
  const overwrite = await confirm({ message: "The style definition file already exists, do you want to overwrite it?" });
784
1074
  if (!overwrite) {
785
1075
  process.stdout.write("\n");
@@ -788,8 +1078,8 @@ const StylesCreateCommand = {
788
1078
  }
789
1079
  }
790
1080
  // @ToDo: Add properties
791
- fs.writeFileSync(path.join(basePath, styleFilePath), JSON.stringify(definition, undefined, 4));
792
- process.stdout.write(chalk.yellowBright(chalk.bold(`\nWritten style defintion template to: ${path.normalize(path.join(basePath, styleFilePath))}\n`)));
1081
+ fs.writeFileSync(styleFilePath, JSON.stringify(definition, undefined, 4));
1082
+ process.stdout.write(chalk.yellowBright(chalk.bold(`\nWritten style defintion template to: ${path.normalize(styleFilePath)}\n`)));
793
1083
  if (await confirm({ message: "Do you want to publish this style into Optimizely CMS?" })) {
794
1084
  const response = await client.displayTemplatesCreate({ body: definition }).catch(_ => undefined);
795
1085
  if (!response) {
@@ -811,6 +1101,91 @@ const StylesCreateCommand = {
811
1101
  }
812
1102
  };
813
1103
 
1104
+ const StylesDeleteCommand = {
1105
+ command: "styles:delete",
1106
+ describe: "Remove Visual Builder style definitions from the CMS",
1107
+ builder: (yargs) => {
1108
+ const newYargs = stylesBuilder(yargs);
1109
+ newYargs.option('force', { alias: 'f', description: "Overwrite existing files", boolean: true, type: 'boolean', demandOption: false, default: false });
1110
+ newYargs.option('withStyleFile', { alias: 'w', description: "Delete the .opti-style.json file as weill", boolean: true, type: 'boolean', demandOption: false, default: true });
1111
+ newYargs.option("definitions", { alias: 'u', description: "Update typescript definitions", boolean: true, type: 'boolean', demandOption: false, default: true });
1112
+ return newYargs;
1113
+ },
1114
+ handler: async (args) => {
1115
+ const { components: basePath } = parseArgs(args);
1116
+ const client = createCmsClient(args);
1117
+ const { styles, all: allStyles } = await getStyles(client, args, 100);
1118
+ if (styles.findIndex(x => x.isDefault) >= 0)
1119
+ process.stdout.write(chalk.redBright(chalk.bold((`\n${figures.warning} You are deleting a default Display Template this may lead to unpredicted behavior.\n\n`))));
1120
+ if (!args.force) {
1121
+ process.stdout.write(`This will remove the following display templates\n`);
1122
+ for (const style of styles) {
1123
+ process.stdout.write(` ${figures.arrowRight} ${style.displayName || style.key} [Key: ${style.key}; Default: ${style.isDefault ? 'Yes' : 'No'}]\n`);
1124
+ }
1125
+ process.stdout.write(`\nRun with -f to actually preform removal\n`);
1126
+ return;
1127
+ }
1128
+ const keysToDelete = styles.map(displayTemplate => displayTemplate.key);
1129
+ const styleHelpers = await toTypeFilesList(allStyles, client, basePath);
1130
+ for (const displayTemplate of styles) {
1131
+ const { styleFile: styleFilePath, styleFolder: itemPath, identifier: targetType, helperFolder: typesPath } = styleHelpers.getDisplayTemplatePathsByKey(displayTemplate.key);
1132
+ // Remove style file
1133
+ if (args.withStyleFile) {
1134
+ process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Removing *.opti-style.json file for ${displayTemplate.displayName} [${displayTemplate.key}]\n`));
1135
+ await fsAsync.rm(styleFilePath, { force: false, recursive: false, maxRetries: 1 }).catch((e) => {
1136
+ if (e?.code === 'ENOENT') // The file can't be deleted as it does not exist - that's the desired state
1137
+ return;
1138
+ throw e;
1139
+ });
1140
+ await fsAsync.rmdir(itemPath, { recursive: false, maxRetries: 1 }).catch((e) => {
1141
+ if (e?.code === 'ENOTEMPTY') {
1142
+ process.stdout.write(chalk.yellowBright(`${figures.warning} The folder is not empty, please remove ${itemPath} manually if needed\n`));
1143
+ return;
1144
+ }
1145
+ else if (e?.code === 'ENOENT')
1146
+ return;
1147
+ else
1148
+ throw e;
1149
+ });
1150
+ }
1151
+ // Remove/update typescript helper
1152
+ if (args.definitions && styleHelpers.has(targetType)) {
1153
+ process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Removing/updating displayTemplates.ts file for ${displayTemplate.displayName} [${displayTemplate.key}]\n`));
1154
+ const remainingTemplates = styleHelpers[targetType].templates.filter(x => !keysToDelete.includes(x.key));
1155
+ if (remainingTemplates.length === 0) {
1156
+ await fsAsync.rm(styleHelpers[targetType].filePath, { force: false, recursive: false, maxRetries: 1 }).catch((e) => {
1157
+ if (e?.code === 'ENOENT') // The file can't be deleted as it does not exist - that's the desired state
1158
+ return;
1159
+ throw e;
1160
+ });
1161
+ await fsAsync.rmdir(typesPath, { recursive: false, maxRetries: 1 }).catch((e) => {
1162
+ if (e?.code === 'ENOTEMPTY') {
1163
+ process.stdout.write(chalk.yellowBright(`${figures.warning} The folder is not empty, please remove ${typesPath} manually if needed\n`));
1164
+ return;
1165
+ }
1166
+ else if (e?.code === 'ENOENT')
1167
+ return;
1168
+ else
1169
+ throw e;
1170
+ });
1171
+ }
1172
+ else {
1173
+ const newEntry = {
1174
+ filePath: styleHelpers[targetType].filePath,
1175
+ templates: remainingTemplates
1176
+ };
1177
+ if (!await createDisplayTemplateHelper(newEntry, targetType, false, false))
1178
+ process.stdout.write(chalk.yellowBright(`${figures.warning} The display template was not updated, please update ${newEntry.filePath} manually`));
1179
+ }
1180
+ }
1181
+ // Actually remove from CMS
1182
+ process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Removing the display template ${displayTemplate.displayName} [${displayTemplate.key}] from Optimizely CMS\n`));
1183
+ void await client.displayTemplatesDelete({ path: { key: displayTemplate.key } });
1184
+ }
1185
+ process.stdout.write("\n" + chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
1186
+ }
1187
+ };
1188
+
814
1189
  const TypesPullCommand = {
815
1190
  command: "types:pull",
816
1191
  describe: "Pull content type definition files into the project",
@@ -820,13 +1195,13 @@ const TypesPullCommand = {
820
1195
  return newArgs;
821
1196
  },
822
1197
  handler: async (args) => {
823
- const { _config: { debug }, components: basePath, force } = parseArgs(args);
1198
+ const { _config: { debug }, components: basePath, path: projectPath, force } = parseArgs(args);
824
1199
  const client = createCmsClient(args);
825
1200
  const { contentTypes } = await getContentTypes(client, args);
826
1201
  const updatedTypes = contentTypes.map(contentType => {
827
- const { typePath, typeFile } = getContentTypePaths(contentType, basePath);
828
- if (!fs.existsSync(typePath))
829
- fs.mkdirSync(typePath, { recursive: true });
1202
+ const { path: path$1, typeFile } = getContentTypePaths(contentType, basePath);
1203
+ if (!fs.existsSync(path$1))
1204
+ fs.mkdirSync(path$1, { recursive: true });
830
1205
  if (fs.existsSync(typeFile) && !force) {
831
1206
  if (debug)
832
1207
  process.stdout.write(chalk.yellow(`${figures.cross} Skipping type definition for ${contentType.displayName} (${contentType.key}) - File already exists\n`));
@@ -835,9 +1210,7 @@ const TypesPullCommand = {
835
1210
  const outContentType = { ...contentType };
836
1211
  if (outContentType.source || outContentType.source == "")
837
1212
  delete outContentType.source;
838
- //if (outContentType.features) delete outContentType.features
839
- //if (outContentType.usage) delete outContentType.usage
840
- if (outContentType.lastModifiedBy)
1213
+ if (outContentType.lastModifiedBy || outContentType.lastModifiedBy == "")
841
1214
  delete outContentType.lastModifiedBy;
842
1215
  if (outContentType.lastModified)
843
1216
  delete outContentType.lastModified;
@@ -858,16 +1231,13 @@ const TypesPullCommand = {
858
1231
  }
859
1232
  }
860
1233
  if (debug)
861
- process.stdout.write(chalk.gray(`${figures.arrowRight} Writing type definition for ${contentType.displayName} (${contentType.key})\n`));
1234
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Writing type definition for ${contentType.displayName} (${contentType.key}) into ${path.relative(projectPath, path$1)}\n`));
862
1235
  fs.writeFileSync(typeFile, JSON.stringify(outContentType, undefined, 2));
863
1236
  return contentType.key;
864
1237
  }).filter(x => x);
865
1238
  process.stdout.write(chalk.green(chalk.bold(`${figures.tick} Created/updated type definitions for ${updatedTypes.join(', ')}\n`)));
866
1239
  }
867
1240
  };
868
- function isEmptyArray(toTest) {
869
- return toTest === null || !Array.isArray(toTest) || toTest.length === 0;
870
- }
871
1241
 
872
1242
  const deepmerge$1 = createDeepMerge();
873
1243
  async function loadSchema(client, schemaName) {
@@ -1169,14 +1539,6 @@ function getTypeFolder(list, type) {
1169
1539
  return list.filter(x => x.type == type).at(0);
1170
1540
  }
1171
1541
 
1172
- function ucFirst(current) {
1173
- if (typeof current != 'string')
1174
- throw new Error("Only strings can be transformed");
1175
- if (current == "")
1176
- return current;
1177
- return current[0]?.toUpperCase() + current.substring(1);
1178
- }
1179
-
1180
1542
  const NextJsComponentsCommand = {
1181
1543
  command: "nextjs:components",
1182
1544
  describe: "Create the React Components for a Next.JS / Optimizely Graph structure",
@@ -1286,8 +1648,9 @@ ${varName}.getDataFragment = () => ['${contentType.key}Data', ${contentType.key}
1286
1648
 
1287
1649
  export default ${varName}`,
1288
1650
  // Default Template for all section types
1289
- section: (contentType, varName, displayTemplate, baseDisplayTemplate) => `import { CmsEditable, type CmsComponent } from "@remkoj/optimizely-cms-react/rsc";
1290
- import { ${contentType.key}DataFragmentDoc, type ${contentType.key}DataFragment } from "@/gql/graphql";${displayTemplate ? `
1651
+ section: (contentType, varName, displayTemplate, baseDisplayTemplate) => `import { OptimizelyComposition, isNode, CmsEditable, type CmsComponent } from "@remkoj/optimizely-cms-react/rsc";
1652
+ import { get${contentType.key}DataDocument, type get${contentType.key}DataQuery, SectionCompositionDataFragmentDoc } from "@/gql/graphql";
1653
+ import { getFragmentData } from "@/gql/fragment-masking";${displayTemplate ? `
1291
1654
  import { ${displayTemplate} } from "./displayTemplates";` : ''}${baseDisplayTemplate ? `
1292
1655
  import { ${baseDisplayTemplate} } from "../styles/displayTemplates";` : ''}
1293
1656
 
@@ -1296,19 +1659,25 @@ import { ${baseDisplayTemplate} } from "../styles/displayTemplates";` : ''}
1296
1659
  * ---
1297
1660
  * ${contentType.description}
1298
1661
  */
1299
- export const ${varName} : CmsComponent<${contentType.key}DataFragment${(displayTemplate || baseDisplayTemplate) ? ', ' + [displayTemplate, baseDisplayTemplate].filter(x => x).join(" | ") : ''}> = ({ data${(displayTemplate || baseDisplayTemplate) ? ', layoutProps' : ''}, editProps, children }) => {
1300
- const componentName = '${contentType.displayName}'
1301
- const componentInfo = '${contentType.description?.replaceAll("'", "\\'") ?? ''}'
1302
- return <CmsEditable className="w-full border-y border-y-solid border-y-slate-900 py-2 mb-4" {...editProps}>
1303
- <div className="font-bold italic">{ componentName }</div>
1304
- <div>{ componentInfo }</div>
1305
- { Object.getOwnPropertyNames(data).length > 0 && <pre className="w-full overflow-x-hidden font-mono text-sm bg-slate-200 p-2 rounded-sm border border-solid border-slate-900 text-slate-900">{ JSON.stringify(data, undefined, 4) }</pre> }
1306
- ${(displayTemplate || baseDisplayTemplate) ? '<pre className="w-full overflow-x-hidden font-mono text-sm bg-slate-200 p-2 rounded-sm border border-solid border-slate-900 text-slate-900">{ JSON.stringify(layoutProps, undefined, 4) }</pre>' : '{/* This component doesn\'t have layout options */}'}
1307
- <div>{ children }</div>
1308
- </CmsEditable>
1662
+ export const ${varName} : CmsComponent<get${contentType.key}DataQuery${(displayTemplate || baseDisplayTemplate) ? ', ' + [displayTemplate, baseDisplayTemplate].filter(x => x).join(" | ") : ''}> = ({ data${(displayTemplate || baseDisplayTemplate) ? ', layoutProps' : ''}, editProps, children, ctx }) => {
1663
+ // If we're rendering stand-alone we'll get a composition back, with ourself included If we're
1664
+ // rendering as part of an experience, we'll get the data directly. So handle both cases.
1665
+ if (!data?._metadata && children) return children;
1666
+
1667
+ const componentName = '${contentType.displayName}'
1668
+ const componentInfo = '${contentType.description?.replaceAll("'", "\\'") ?? ''}'
1669
+ const composition = getFragmentData(SectionCompositionDataFragmentDoc, data)?.composition;
1670
+ return <CmsEditable className="w-full border-y border-y-solid border-y-slate-900 py-2 mb-4" {...editProps}>
1671
+ <div className="font-bold italic">{ componentName }</div>
1672
+ <div>{ componentInfo }</div>
1673
+ { Object.getOwnPropertyNames(data).length > 0 && <pre className="w-full overflow-x-hidden font-mono text-sm bg-slate-200 p-2 rounded-sm border border-solid border-slate-900 text-slate-900">{ JSON.stringify(data, undefined, 4) }</pre> }
1674
+ ${(displayTemplate || baseDisplayTemplate) ? '<pre className="w-full overflow-x-hidden font-mono text-sm bg-slate-200 p-2 rounded-sm border border-solid border-slate-900 text-slate-900">{ JSON.stringify(layoutProps, undefined, 4) }</pre>' : '{/* This component doesn\'t have layout options */}'}
1675
+ {children && <div>{ children }</div>}
1676
+ {composition && isNode(composition) && <OptimizelyComposition node={composition} ctx={ctx} />}
1677
+ </CmsEditable>
1309
1678
  }
1310
1679
  ${varName}.displayName = "${contentType.displayName} (${ucFirst(contentType.baseType)}/${contentType.key})"
1311
- ${varName}.getDataFragment = () => ['${contentType.key}Data', ${contentType.key}DataFragmentDoc]
1680
+ ${varName}.getDataQuery = () => get${contentType.key}DataDocument
1312
1681
 
1313
1682
  export default ${varName}`,
1314
1683
  // Template for all page component types
@@ -1401,13 +1770,13 @@ const NextJsVisualBuilderCommand = {
1401
1770
  const { styles } = await getStyles(createCmsClient(args), { ...args, excludeNodeTypes: [], excludeTemplates: [], nodes: [], templates: [], templateTypes: ['node', 'base'] });
1402
1771
  // Process node styles
1403
1772
  styles.filter(x => typeof (x.nodeType) == 'string' && x.nodeType.length > 0).map(styleDefintion => {
1404
- const templatePath = path.join(basePath, 'nodes', styleDefintion.nodeType, styleDefintion.key);
1773
+ const templatePath = path.join(basePath, 'nodes', keyToSlug(styleDefintion.nodeType), keyToSlug(styleDefintion.key));
1405
1774
  createSpecificNode(styleDefintion, templatePath, force, debug);
1406
1775
  });
1407
1776
  // Process base styles
1408
1777
  styles.filter(x => typeof (x.baseType) == 'string' && x.baseType.length > 0).map(styleDefinition => {
1409
1778
  const baseType = (styleDefinition.baseType ?? '').startsWith('_') ? (styleDefinition.baseType ?? '').substring(1) : styleDefinition.baseType;
1410
- const templatePath = path.join(basePath, baseType, 'styles', styleDefinition.key);
1779
+ const templatePath = path.join(basePath, baseType, 'styles', keyToSlug(styleDefinition.key));
1411
1780
  createSpecificNode(styleDefinition, templatePath, force, debug);
1412
1781
  });
1413
1782
  }
@@ -1417,14 +1786,14 @@ function createSpecificNode(template, templatePath, force = false, debug = false
1417
1786
  if (fs.existsSync(nodeFile)) {
1418
1787
  if (!force) {
1419
1788
  if (debug)
1420
- process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${template.displayName} (${template.key}) component - file already exists\n`));
1789
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${template.displayName} (${template.key}) node - file already exists\n`));
1421
1790
  return undefined;
1422
1791
  }
1423
1792
  if (debug)
1424
- process.stdout.write(chalk.gray(`${figures.arrowRight} Overwriting ${template.displayName} (${template.key}) component\n`));
1793
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Overwriting ${template.displayName} (${template.key}) node\n`));
1425
1794
  }
1426
1795
  else if (debug)
1427
- process.stdout.write(chalk.gray(`${figures.arrowRight} Creating ${template.displayName} (${template.key}) component\n`));
1796
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Creating ${template.displayName} (${template.key}) node\n`));
1428
1797
  if (!fs.existsSync(templatePath))
1429
1798
  fs.mkdirSync(templatePath, { recursive: true });
1430
1799
  const baseType = template.nodeType ?? template.baseType ?? 'unknown';
@@ -1502,6 +1871,7 @@ function getDisplayTemplateInfo(template, typePath) {
1502
1871
 
1503
1872
  const ROOT_FACTORY_KEY = ".";
1504
1873
  const FACTORY_FILE_NAME = "index.ts";
1874
+ const reservedNames = ["loading.tsx", "loading.jsx", "suspense.tsx", "suspense.jsx"];
1505
1875
  const NextJsFactoryCommand = {
1506
1876
  command: "nextjs:factory",
1507
1877
  describe: "Create the ComponentFactory for a Next.JS / Optimizely Graph structure",
@@ -1510,108 +1880,102 @@ const NextJsFactoryCommand = {
1510
1880
  const { components: basePath, force, _config: { debug } } = parseArgs(args);
1511
1881
  if (debug)
1512
1882
  process.stdout.write(chalk.gray(`${figures.arrowRight} Start generating component factories\n`));
1883
+ // Get & filter all component files
1884
+ const clientComponents = [];
1513
1885
  const components = globSync(["./**/*.jsx", "./**/*.tsx"], {
1514
1886
  cwd: basePath
1515
1887
  }).map(p => p.split(path.sep)).filter(p => {
1888
+ // Get the filename
1889
+ const fileName = p.at(p.length - 1);
1890
+ if (!fileName)
1891
+ return false;
1516
1892
  // Consider components in a file starting with "_" as a partial
1517
- if (p.at(p.length - 1)?.startsWith('_') == true)
1893
+ if (fileName.startsWith('_') == true)
1518
1894
  return false;
1519
1895
  // Consider components in a folder named "partials" as a partial
1520
- if (p.at(p.length - 2)?.toLowerCase() == 'partials')
1896
+ if (p.some(folder => folder === 'partials'))
1521
1897
  return false;
1522
1898
  // Skip the special loader component
1523
- if (p.at(p.length - 1) == "loading.tsx" || p.at(p.length - 1) == "loading.jsx")
1899
+ if (reservedNames.includes(fileName))
1524
1900
  return false;
1525
1901
  // Check if the file has a default export
1526
1902
  const fileBuffer = fs.readFileSync(path.join(basePath, p.join(path.sep)));
1527
1903
  const hasDefaultExport = fileBuffer.includes('export default');
1904
+ if (fileBuffer.includes('use client'))
1905
+ clientComponents.push(p.join(path.sep));
1528
1906
  if (!hasDefaultExport) {
1529
1907
  process.stdout.write(chalk.redBright(`${figures.warning} No default export in ${p.join(path.sep)} - ignoring file\n`));
1530
1908
  return false;
1531
1909
  }
1532
1910
  return true;
1533
1911
  });
1912
+ // Report what we have found
1534
1913
  if (debug)
1535
1914
  process.stdout.write(chalk.gray(`${figures.arrowRight} Identified ${components.length} components in ${basePath}\n`));
1915
+ // Build factory / component structure
1536
1916
  const componentFactoryDefintions = new Map();
1537
1917
  components.forEach(component => {
1918
+ const componentDir = path.dirname(path.join(...component));
1919
+ path.basename(path.join(...component));
1920
+ // Determine component target
1921
+ const componentKey = getComponentKey(component, basePath);
1922
+ let componentVariant = (component.length > 1 ? component.at(component.length - 1) ?? 'default' : 'default').replace('index', 'default');
1923
+ componentVariant = path.basename(componentVariant, path.extname(componentVariant));
1924
+ // Get factory information
1538
1925
  const factorySegments = component.length > 2 ? component.slice(0, -2) : [ROOT_FACTORY_KEY];
1539
- const factoryKey = factorySegments.join(path.sep);
1926
+ const factoryKey = path.posix.join(...factorySegments);
1540
1927
  const factoryFile = path.join(factoryKey, FACTORY_FILE_NAME);
1541
- // Add component to factory
1928
+ // Check dynamic & suspense
1929
+ const isClientComponent = clientComponents.includes(component.join(path.sep));
1930
+ const useDynamic = [
1931
+ path.join(basePath, componentDir, 'loading.tsx'),
1932
+ path.join(basePath, componentDir, 'loading.jsx')
1933
+ ].some(fs.existsSync);
1934
+ const useSuspense = [
1935
+ path.join(basePath, componentDir, 'suspense.tsx'),
1936
+ path.join(basePath, componentDir, 'suspense.jsx')
1937
+ ].some(fs.existsSync);
1938
+ // Prepare data
1939
+ const componentImport = component.length == 1 ?
1940
+ `.${path.posix.sep}${path.basename(component[0], path.extname(component[0]))}` :
1941
+ `.${path.posix.sep}${path.posix.relative(factoryKey, componentDir)}${componentVariant !== 'default' ? path.posix.sep + componentVariant : ''}`;
1942
+ const loaderImport = useDynamic ? componentImport + path.posix.sep + 'loading' : undefined;
1943
+ const suspenseImport = useSuspense ? componentImport + path.posix.sep + 'suspense' : undefined;
1944
+ const componentVariablesBase = componentKey + (componentVariant !== 'default' ? processName(componentVariant) : '');
1945
+ // Add to the factory
1542
1946
  const factory = componentFactoryDefintions.get(factoryKey) || { file: factoryFile, entries: [], subfactories: [] };
1543
- const useSuspense = fs.existsSync(path.join(basePath, component.slice(0, -1).join(path.sep), 'loading.tsx')) || fs.existsSync(path.join(basePath, component.slice(0, -1).join(path.sep), 'loading.jsx'));
1544
- if (useSuspense && debug)
1545
- process.stdout.write(chalk.gray(`${figures.arrowRight} Components in ${component.slice(0, -1).join(path.sep)} will use suspense\n`));
1546
- const componentSegments = component.slice(-2).map(p => {
1547
- const entry = p.substring(0, p.length - path.extname(p).length);
1548
- return entry.toLowerCase() == "index" ? null : entry;
1549
- }).filter(x => x);
1550
- const componentImport = "./" + componentSegments.join('/');
1551
- const loaderImport = useSuspense ? "./" + component.slice(-2).map((p, i, a) => {
1552
- const entry = p.substring(0, p.length - path.extname(p).length);
1553
- if (i == a.length - 1)
1554
- return 'loading';
1555
- return entry.toLowerCase() == "index" ? null : entry;
1556
- }).join('/') : undefined;
1557
1947
  factory.entries.push({
1948
+ key: componentKey,
1949
+ variant: componentVariant,
1558
1950
  import: componentImport,
1559
- variable: [...componentSegments.map(processName), 'Component'].join(''),
1560
- key: componentSegments.join('/') == "node" ? "Node" : componentSegments.join('/'),
1951
+ variable: componentVariablesBase + 'Component',
1561
1952
  loaderImport,
1562
- loaderVariable: useSuspense ? [...componentSegments.map(processName), 'Loader'].join('') : undefined,
1953
+ loaderVariable: useDynamic ? componentVariablesBase + 'Loader' : undefined,
1954
+ suspenseImport,
1955
+ suspenseVariable: useSuspense ? componentVariablesBase + 'Placeholder' : undefined,
1956
+ isClient: isClientComponent
1563
1957
  });
1564
1958
  componentFactoryDefintions.set(factoryKey, factory);
1565
- // Register with all appropriate parents
1959
+ // Add/update parent factories
1566
1960
  const parentSegements = factoryKey == ROOT_FACTORY_KEY ? factorySegments.slice(0, -1) : [ROOT_FACTORY_KEY, ...factorySegments.slice(0, -1)];
1567
- let currentFactory = {
1568
- key: factoryKey,
1569
- import: './' + factorySegments.slice(-1).join('/'),
1570
- prefix: processName(factorySegments.slice(-1).at(0)),
1571
- variable: factorySegments.map(processName).join("") + "Factory"
1572
- };
1573
- for (let i = parentSegements.length; i > 0; i--) {
1574
- const parentFactoryKey = parentSegements.slice(0, i).filter(x => x != ROOT_FACTORY_KEY).join(path.sep) || ROOT_FACTORY_KEY;
1961
+ parentSegements.forEach((_, idx, data) => {
1962
+ const parentFactoryKey = path.posix.join(...data.slice(0, idx + 1)) || ROOT_FACTORY_KEY;
1963
+ const childFactoryKey = factorySegments.slice(idx, idx + 1).at(0);
1575
1964
  const parentFactory = componentFactoryDefintions.get(parentFactoryKey) ?? { file: path.join(parentFactoryKey, FACTORY_FILE_NAME), entries: [], subfactories: [] };
1576
- if (!parentFactory.subfactories.some(x => x.key == currentFactory.key))
1577
- parentFactory.subfactories.push(currentFactory);
1578
- componentFactoryDefintions.set(parentFactoryKey, parentFactory);
1579
- if (i > 1) {
1580
- currentFactory = {
1581
- key: parentFactoryKey,
1582
- import: './' + parentFactoryKey.split(path.sep).slice(-1).at(0),
1583
- prefix: processName(parentFactoryKey.split(path.sep).slice(-1).at(0)),
1584
- variable: parentFactoryKey.split(path.sep).map(processName).join("") + "Factory"
1585
- };
1965
+ if (!parentFactory.subfactories.some(x => x.key === childFactoryKey)) {
1966
+ parentFactory.subfactories.push({
1967
+ key: childFactoryKey,
1968
+ import: './' + childFactoryKey,
1969
+ variable: processName(childFactoryKey) + 'Factory'
1970
+ });
1971
+ componentFactoryDefintions.set(parentFactoryKey, parentFactory);
1586
1972
  }
1587
- }
1588
- });
1589
- const mainFactory = componentFactoryDefintions.get(ROOT_FACTORY_KEY);
1590
- if (mainFactory) {
1591
- if (debug)
1592
- process.stdout.write(chalk.gray(`${figures.arrowRight} Updating prefixes within RootFactory\n`));
1593
- mainFactory.subfactories = mainFactory.subfactories.map(subFactory => {
1594
- if (typeof (subFactory.prefix == 'string'))
1595
- switch (subFactory.prefix) {
1596
- case "Video":
1597
- case "Image":
1598
- subFactory.prefix = ["Media", subFactory.prefix, "Component"];
1599
- break;
1600
- case "Experience":
1601
- subFactory.prefix = [subFactory.prefix, "Page"];
1602
- break;
1603
- case "Element":
1604
- subFactory.prefix = ["Component"];
1605
- break;
1606
- case "Media":
1607
- subFactory.prefix = [subFactory.prefix, "Component"];
1608
- break;
1609
- }
1610
- return subFactory;
1611
1973
  });
1612
- }
1974
+ });
1975
+ // Report factory file count
1613
1976
  if (debug)
1614
1977
  process.stdout.write(chalk.gray(`${figures.arrowRight} Finished preparing ${componentFactoryDefintions.size} factories, start writing\n`));
1978
+ // Iterate over the factories and create them
1615
1979
  let updateCounter = 0;
1616
1980
  for (const key of componentFactoryDefintions.keys()) {
1617
1981
  const factory = componentFactoryDefintions.get(key);
@@ -1635,6 +1999,36 @@ const NextJsFactoryCommand = {
1635
1999
  process.stdout.write("\n");
1636
2000
  }
1637
2001
  };
2002
+ /**
2003
+ * Get the component key that must be used for the variable in the factory
2004
+ * this looks first at the component-type file.
2005
+ *
2006
+ * @param component The file path broken into parts
2007
+ * @returns The key to use
2008
+ */
2009
+ function getComponentKey(component, componentsRootDir) {
2010
+ // Prepare context
2011
+ //const componentFile = path.basename(path.join(...component));
2012
+ const componentDir = path.dirname(path.join(...component));
2013
+ // Calculate the fallback value
2014
+ const baseName = processName(component.length == 1 ? ucFirst(path.basename(component[0], path.extname(component[0]))) : component.at(component.length - 2));
2015
+ // Try to read the key from the opti-type.json file in the same folder
2016
+ const definitions = globSync("*.opti-type.json", { cwd: path.join(componentsRootDir, componentDir) });
2017
+ //console.log(definitions, componentDir);
2018
+ if (definitions.length == 1) {
2019
+ try {
2020
+ const data = JSON.parse(fs.readFileSync(path.join(componentsRootDir, componentDir, definitions[0])).toString('utf8'));
2021
+ //console.log('Updating to', data.key)
2022
+ if (data.key)
2023
+ return data.key;
2024
+ }
2025
+ catch {
2026
+ // Ignored on purpose
2027
+ }
2028
+ }
2029
+ // Return the fallback value
2030
+ return baseName;
2031
+ }
1638
2032
  function shouldWriteFactory(factoryFile, force = false, debug = false) {
1639
2033
  if (!fs.existsSync(factoryFile)) {
1640
2034
  process.stdout.write(chalk.green(`${figures.tick} Creating new factory file: ${factoryFile}\n`));
@@ -1660,59 +2054,64 @@ function processName(input) {
1660
2054
  return nameSegements.map(ucFirst).join('');
1661
2055
  }
1662
2056
  function generateFactory(factoryInfo, factoryKey) {
1663
- const needsPrefixFunction = factoryInfo.subfactories.length > 0;
2057
+ // Get the factory name
1664
2058
  const factoryName = factoryKey.split(path.sep).map(processName).join("") + "Factory";
1665
- const components = factoryInfo.entries;
1666
- const subFactories = factoryInfo.subfactories;
1667
- const factoryContent = `// Auto generated dictionary
2059
+ // Get the components and sub-factories, sorted by key to minimize changes between runs
2060
+ const components = [...factoryInfo.entries].sort((a, b) => { return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; });
2061
+ const subFactories = [...factoryInfo.subfactories].sort((a, b) => { return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; });
2062
+ // Check if there's at least one component that uses next/dynamic
2063
+ const hasDynamic = factoryInfo.entries.some(x => x.loaderImport);
2064
+ const hasClientComponents = factoryInfo.entries.some(x => x.isClient);
2065
+ // The intro for the factory
2066
+ const factoryIntro = `// Auto generated dictionary
1668
2067
  // @not-modified => When this line is removed, the "force" parameter of the CLI tool is required to overwrite this file
1669
- import { type ComponentTypeDictionary } from "@remkoj/optimizely-cms-react";
1670
- ${[...components, ...subFactories].map(x => {
1671
- let importLine = `import ${x.variable} from "${x.import}";`;
1672
- if (x.loaderImport && x.loaderVariable) {
1673
- importLine += `\nimport ${x.loaderVariable} from "${x.loaderImport}";`;
1674
- }
1675
- return importLine;
1676
- }).join("\n")}
1677
-
1678
- ${needsPrefixFunction ? `// Prefix entries - if needed
1679
- ${subFactories.map(subFactory => Array.isArray(subFactory.prefix) ?
1680
- subFactory.prefix.map(z => `prefixDictionaryEntries(${subFactory.variable}, "${z}");`).join("\n") :
1681
- `prefixDictionaryEntries(${subFactory.variable}, "${subFactory.prefix}");`).join("\n")}
1682
-
1683
- ` : ''}// Build dictionary
1684
- export const ${factoryName} : ComponentTypeDictionary = [
1685
- ${[...components.map(x => {
1686
- if (x.loaderVariable) {
1687
- return `{
1688
- type: "${x.key}",
1689
- component: ${x.variable},
1690
- useSuspense: true,
1691
- loader: ${x.loaderVariable}
1692
- }`;
1693
- }
1694
- else {
1695
- return `{
1696
- type: "${x.key}",
1697
- component: ${x.variable}
1698
- }`;
1699
- }
1700
- }), ...subFactories.map(x => `...${x.variable}`)].join(",\n ")}
1701
- ];
1702
-
1703
- // Export dictionary
1704
- export default ${factoryName};
1705
- ${needsPrefixFunction ? `
1706
- // Helper functions
1707
- function prefixDictionaryEntries(list: ComponentTypeDictionary, prefix: string) : ComponentTypeDictionary
1708
- {
1709
- list.forEach((component, idx, dictionary) => {
1710
- dictionary[idx].type = typeof component.type == 'string' ? prefix + "/" + component.type : [ prefix, ...component.type ]
1711
- });
1712
- return list;
1713
- }
1714
- ` : ''}`;
1715
- return factoryContent;
2068
+ import { type ComponentTypeDictionary } from '@remkoj/optimizely-cms-react';${hasDynamic || hasClientComponents ? `
2069
+ import dynamic from 'next/dynamic';` : ''}`;
2070
+ // The outry for the factory
2071
+ const factoryOutro = `// Export dictionary
2072
+ export default ${factoryName};`;
2073
+ // The imports of the factory
2074
+ const factoryImports = [...components, ...subFactories].map(x => {
2075
+ const imports = [];
2076
+ if (x.loaderImport)
2077
+ imports.push(`import ${x.loaderVariable} from '${x.loaderImport}';`);
2078
+ else if (!x.isClient)
2079
+ imports.push(`import ${x.variable} from '${x.import}';`);
2080
+ if (x.suspenseImport)
2081
+ imports.push(`import ${x.suspenseVariable} from '${x.suspenseImport}';`);
2082
+ return imports.length > 0 ? imports.join('\n') : undefined;
2083
+ }).filter(x => x).join('\n');
2084
+ // The dynamic imports of the factory
2085
+ const dynamicImports = hasDynamic ? `// Lazy load components that have a loading file, this only affects client components
2086
+ // See https://nextjs.org/docs/app/guides/lazy-loading#importing-server-components
2087
+ // for more details on how this affects server components in Next.js
2088
+ ` + components.filter(x => x.loaderImport).map(x => {
2089
+ return `const ${x.variable} = dynamic(() => import('${x.import}'), {
2090
+ ssr: true,
2091
+ loading: ${x.loaderVariable}
2092
+ });`;
2093
+ }).join('\n') : undefined;
2094
+ // The client imports of the factory
2095
+ const clientComponents = hasClientComponents ? `// Lazy load client components even without loader specified
2096
+ ` + components.filter(x => x.isClient && !x.loaderImport).map(x => {
2097
+ return `const ${x.variable} = dynamic(() => import('${x.import}'), { ssr: true });`;
2098
+ }).join('\n') : undefined;
2099
+ // The actual entries for the factory
2100
+ const factoryEntries = [...components.map(x => {
2101
+ return ` {
2102
+ type: '${x.key}',${x.variant && x.variant !== 'default' ? `
2103
+ variant: '${x.variant}',` : ''}
2104
+ component: ${x.variable}${x.suspenseVariable ? `,
2105
+ useSuspense: true,
2106
+ loader: ${x.suspenseVariable}` : ''}${x.isClient ? `,
2107
+ isClient: true` : ''}
2108
+ }`;
2109
+ }), ...subFactories.map(x => ` ...${x.variable}`)];
2110
+ // The body of the factory
2111
+ const factoryBody = `// Build dictionary
2112
+ export const ${factoryName} : ComponentTypeDictionary = [${factoryEntries.length > 0 ? '\n' + factoryEntries.join(',\n') + '\n' : ''}];`;
2113
+ // Combine everything into one string
2114
+ return [factoryIntro, factoryImports, dynamicImports, clientComponents, factoryBody, factoryOutro].filter(x => (x?.length || 0) > 0).join('\n\n') + '\n';
1716
2115
  }
1717
2116
 
1718
2117
  const NextJsCreateCommand = {
@@ -1752,16 +2151,23 @@ const NextJsFragmentsCommand = {
1752
2151
  handler: async (args, opts) => {
1753
2152
  // Prepare
1754
2153
  const { loadedContentTypes, createdTypeFolders } = opts || {};
1755
- const { components: basePath, _config: { debug }, force } = parseArgs(args);
2154
+ const { components: basePath, _config: { debug }, force, path: appPath } = parseArgs(args);
1756
2155
  const client = createCmsClient(args);
2156
+ // Get content types
1757
2157
  const { contentTypes, all: allContentTypes } = loadedContentTypes ?? await getContentTypes(client, args);
2158
+ const allContentTypesMap = new Map();
2159
+ allContentTypes.forEach(x => {
2160
+ if (x.key)
2161
+ allContentTypesMap.set(x.key, x);
2162
+ });
2163
+ const generator = new DocumentGenerator(allContentTypesMap);
1758
2164
  // Start process
1759
2165
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Generating GraphQL fragments\n`));
1760
2166
  const typeFolders = createdTypeFolders ?? createTypeFolders(contentTypes, basePath, debug);
1761
- const tracker = new Map();
2167
+ const tracker = new PropertyCollisionTracker(appPath);
1762
2168
  const dependencies = [];
1763
2169
  const updatedTypes = contentTypes.map(contentType => {
1764
- const { written, propertyTypes } = createComponentFragments(contentType, getTypeFolder(typeFolders, contentType.key), force, debug, tracker);
2170
+ const { written, propertyTypes } = createComponentFragments(contentType, getTypeFolder(typeFolders, contentType.key), force, debug, tracker, generator);
1765
2171
  dependencies.push(...propertyTypes);
1766
2172
  return written ? contentType.key : undefined;
1767
2173
  }).filter(x => x);
@@ -1781,7 +2187,7 @@ const NextJsFragmentsCommand = {
1781
2187
  typeFolders.push(tf);
1782
2188
  }
1783
2189
  return tf;
1784
- }, force, debug);
2190
+ }, force, debug, tracker, generator);
1785
2191
  // Report outcome
1786
2192
  if (generatedProps.length > 0)
1787
2193
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Created/updated GraphQL property fragments for ${generatedProps.join(', ')}\n`));
@@ -1791,7 +2197,7 @@ const NextJsFragmentsCommand = {
1791
2197
  process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
1792
2198
  }
1793
2199
  };
1794
- function createComponentFragments(contentType, typePath, force, debug, propertyTracker = new Map()) {
2200
+ function createComponentFragments(contentType, typePath, force, debug, propertyTracker = new Map(), generator) {
1795
2201
  const baseQueryFile = typePath.fragmentFile;
1796
2202
  let writeFragment = true;
1797
2203
  if (fs.existsSync(baseQueryFile)) {
@@ -1810,13 +2216,13 @@ function createComponentFragments(contentType, typePath, force, debug, propertyT
1810
2216
  }
1811
2217
  let written = false;
1812
2218
  if (writeFragment) {
1813
- const fragment = GraphQLGen.buildFragment(contentType, undefined, false, propertyTracker);
2219
+ const fragment = generator.buildFragment(contentType, undefined, false, propertyTracker);
1814
2220
  fs.writeFileSync(baseQueryFile, fragment);
1815
2221
  written = true;
1816
2222
  }
1817
- return { written, propertyTypes: GraphQLGen.getReferencedPropertyComponents(contentType) };
2223
+ return { written, propertyTypes: DocumentGenerator.getReferencedPropertyComponents(contentType) };
1818
2224
  }
1819
- function createPropertyFragments(propertyTypesList, allContentTypes, selectTypeFolder, force = false, debug = false) {
2225
+ function createPropertyFragments(propertyTypesList, allContentTypes, selectTypeFolder, force = false, debug = false, propertyTracker = new Map(), generator) {
1820
2226
  const returnValue = [];
1821
2227
  for (const propertyContentTypeKey of propertyTypesList.filter((v, i, a) => a.indexOf(v, i + 1) <= i)) {
1822
2228
  // Load the ContentType definition
@@ -1848,16 +2254,16 @@ function createPropertyFragments(propertyTypesList, allContentTypes, selectTypeF
1848
2254
  process.stdout.write(chalk.gray(`${figures.arrowRight} Creating ${contentType.displayName} (${contentType.key}) property fragment\n`));
1849
2255
  }
1850
2256
  if (mustWrite) {
1851
- const fragment = GraphQLGen.buildFragment(contentType, undefined, true);
2257
+ const fragment = generator.buildFragment(contentType, undefined, true, propertyTracker);
1852
2258
  fs.writeFileSync(propertyFragmentFile, fragment);
1853
2259
  returnValue.push(contentType.key);
1854
2260
  }
1855
2261
  // Recurse down for properties that we're not yet rendering
1856
- const referencedPropertyTypes = GraphQLGen.getReferencedPropertyComponents(contentType).filter(x => !propertyTypesList.includes(x));
2262
+ const referencedPropertyTypes = DocumentGenerator.getReferencedPropertyComponents(contentType).filter(x => !propertyTypesList.includes(x));
1857
2263
  if (referencedPropertyTypes.length > 0) {
1858
2264
  if (debug)
1859
2265
  process.stdout.write(chalk.gray(`${figures.arrowRight} Component property ${propertyContentTypeKey} uses components a property, recursing down\n`));
1860
- const additionalProperties = createPropertyFragments(referencedPropertyTypes, allContentTypes, selectTypeFolder, force, debug);
2266
+ const additionalProperties = createPropertyFragments(referencedPropertyTypes, allContentTypes, selectTypeFolder, force, debug, propertyTracker, generator);
1861
2267
  returnValue.push(...additionalProperties);
1862
2268
  }
1863
2269
  }
@@ -1875,16 +2281,23 @@ const NextJsQueriesCommand = {
1875
2281
  handler: async (args, opts) => {
1876
2282
  // Prepare
1877
2283
  const { loadedContentTypes, createdTypeFolders } = opts || {};
1878
- const { components: basePath, _config: { debug }, force } = parseArgs(args);
2284
+ const { components: basePath, _config: { debug }, force, path: appPath } = parseArgs(args);
1879
2285
  const client = createCmsClient(args);
1880
2286
  const { contentTypes, all: allContentTypes } = loadedContentTypes ?? await getContentTypes(client, args);
2287
+ const allContentTypesMap = new Map();
2288
+ allContentTypes.forEach(x => {
2289
+ if (x.key)
2290
+ allContentTypesMap.set(x.key, x);
2291
+ });
2292
+ const generator = new DocumentGenerator(allContentTypesMap);
2293
+ const propertyTracker = new PropertyCollisionTracker(appPath);
1881
2294
  // Start process
1882
2295
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Generating GraphQL queries\n`));
1883
2296
  const dependencies = [];
1884
2297
  const typeFolders = createdTypeFolders ?? createTypeFolders(contentTypes, basePath, debug);
1885
2298
  const updatedTypes = contentTypes.map(contentType => {
1886
2299
  const typePath = getTypeFolder(typeFolders, contentType.key);
1887
- const { written, propertyTypes } = createGraphQueries(contentType, typePath, force, debug);
2300
+ const { written, propertyTypes } = createGraphQueries(contentType, typePath, force, debug, propertyTracker, generator);
1888
2301
  dependencies.push(...propertyTypes);
1889
2302
  return written ? contentType.key : undefined;
1890
2303
  }).filter(x => x).flat();
@@ -1903,7 +2316,7 @@ const NextJsQueriesCommand = {
1903
2316
  typeFolders.push(tf);
1904
2317
  }
1905
2318
  return tf;
1906
- }, force, debug);
2319
+ }, force, debug, propertyTracker, generator);
1907
2320
  // Report outcome
1908
2321
  if (generatedProps.length > 0)
1909
2322
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Created/updated GraphQL property fragments for ${generatedProps.join(', ')}\n`));
@@ -1913,7 +2326,7 @@ const NextJsQueriesCommand = {
1913
2326
  process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
1914
2327
  }
1915
2328
  };
1916
- function createGraphQueries(contentType, typePath, force, debug) {
2329
+ function createGraphQueries(contentType, typePath, force, debug, tracker, generator) {
1917
2330
  const baseQueryFile = typePath.queryFile;
1918
2331
  let mustWrite = true;
1919
2332
  if (fs.existsSync(baseQueryFile)) {
@@ -1931,12 +2344,12 @@ function createGraphQueries(contentType, typePath, force, debug) {
1931
2344
  process.stdout.write(chalk.gray(`${figures.arrowRight} Creating ${contentType.displayName} (${contentType.key}) base fragment\n`));
1932
2345
  }
1933
2346
  if (mustWrite) {
1934
- const fragment = GraphQLGen.buildGetQuery(contentType, undefined, new Map());
2347
+ const fragment = generator.buildGetQuery(contentType, undefined, tracker);
1935
2348
  fs.writeFileSync(baseQueryFile, fragment);
1936
2349
  }
1937
2350
  return {
1938
2351
  written: mustWrite,
1939
- propertyTypes: GraphQLGen.getReferencedPropertyComponents(contentType)
2352
+ propertyTypes: DocumentGenerator.getReferencedPropertyComponents(contentType)
1940
2353
  };
1941
2354
  }
1942
2355
 
@@ -2216,7 +2629,46 @@ function writeFileAsync(path, data) {
2216
2629
  });
2217
2630
  }
2218
2631
 
2219
- var version = "6.0.0-pre9";
2632
+ const MigrateCommand = {
2633
+ command: "project:migrate",
2634
+ describe: "Automate the directory naming convention update",
2635
+ builder,
2636
+ async handler(args, opts) {
2637
+ const { components: componentsPath} = parseArgs(args);
2638
+ const files = await glob(['*/**/*'], { cwd: componentsPath });
2639
+ const operations = [];
2640
+ for (const file of files) {
2641
+ const fullPath = path.join(componentsPath, file);
2642
+ const pathInfo = await fsAsync.stat(fullPath);
2643
+ if (pathInfo.isDirectory()) { // We only need to rename directories
2644
+ const dirName = path.basename(fullPath);
2645
+ const dirParent = path.dirname(fullPath);
2646
+ const newDirName = keyToSlug(dirName);
2647
+ // We're ignoring casing as changing that will confuse Git
2648
+ if (dirName.toLowerCase() !== newDirName.toLowerCase()) {
2649
+ process.stdout.write(`${figures.arrowRight} Updating ${file}\n`);
2650
+ const newFullPath = path.join(dirParent, newDirName);
2651
+ operations.push({ from: fullPath, to: newFullPath });
2652
+ }
2653
+ }
2654
+ if (pathInfo.isFile() && (fullPath.endsWith('.opti-style.json') || fullPath.endsWith('.opti-type.json'))) {
2655
+ const fileName = path.basename(fullPath);
2656
+ const newFileName = keyToSlug(fileName, { preserveCharacters: ['.'] });
2657
+ if (fileName.toLowerCase() !== newFileName.toLowerCase()) {
2658
+ const filePath = path.dirname(fullPath);
2659
+ const newFullPath = path.join(filePath, newFileName);
2660
+ operations.unshift({ from: fullPath, to: newFullPath });
2661
+ }
2662
+ }
2663
+ }
2664
+ for (const operation of operations) {
2665
+ void await fsAsync.rename(operation.from, operation.to);
2666
+ }
2667
+ NextJsFactoryCommand.handler(args);
2668
+ }
2669
+ };
2670
+
2671
+ var version = "6.0.0-rc.1";
2220
2672
  var name = "opti-cms";
2221
2673
  var APP = {
2222
2674
  version: version,
@@ -2241,7 +2693,6 @@ const CmsVersionCommand = {
2241
2693
  throw error;
2242
2694
  }
2243
2695
  });
2244
- const hasPreview2Info = versionInfo.results.preview2Data?.baseUrl ? true : false;
2245
2696
  const info = new Table({
2246
2697
  head: [
2247
2698
  chalk.yellow(chalk.bold("Component")),
@@ -2251,12 +2702,8 @@ const CmsVersionCommand = {
2251
2702
  colAligns: ["left", "left"]
2252
2703
  });
2253
2704
  info.push(["Base URL", versionInfo.baseUrl ?? client.cmsUrl.href]);
2254
- if (hasPreview2Info)
2255
- info.push(["Base URL (Preview 2)", versionInfo.results.preview2Data?.baseUrl ?? 'n/a']);
2256
2705
  info.push(["Client API", client.apiVersion]);
2257
2706
  info.push(["Service API", versionInfo.apiVersion]);
2258
- if (hasPreview2Info)
2259
- info.push(["Service API (Preview 2)", versionInfo.results.preview2Data?.apiVersion ?? 'n/a']);
2260
2707
  info.push(["CMS Build", versionInfo.cmsVersion]);
2261
2708
  info.push(["Service Build", versionInfo.serviceVersion]);
2262
2709
  info.push(["SDK", APP.version]);
@@ -2379,7 +2826,7 @@ async function deleteContentItem(client, key, onlyChildren = false) {
2379
2826
  process.stderr.write(chalk.redBright(` ${figures.cross}The ContentItem ${key} is a reserved item, skipping deletion\n`));
2380
2827
  return removedCount;
2381
2828
  }
2382
- const deleteResult = await client.preview2ContentDelete({ path: { key }, query: { permanent: true } }).then(r => r.key).catch((e) => {
2829
+ const deleteResult = await client.contentDelete({ path: { key }, headers: { "cms-permanent-delete": true } }).then(r => r.key).catch((e) => {
2383
2830
  if (e.status == 404)
2384
2831
  return key;
2385
2832
  throw e;
@@ -2506,7 +2953,7 @@ async function getAllTypes(client, batchSize = 100) {
2506
2953
  return actualItems;
2507
2954
  }
2508
2955
  async function getAllAssets(client, parentKey, batchSize = 100) {
2509
- const items = await client.preview2ContentListAssets({ path: { key: parentKey }, query: { pageIndex: 0, pageSize: batchSize } }).catch((e) => {
2956
+ const items = await client.contentListAssets({ path: { key: parentKey }, query: { pageIndex: 0, pageSize: batchSize } }).catch((e) => {
2510
2957
  if (e.status == 404) {
2511
2958
  return {
2512
2959
  items: [],
@@ -2521,7 +2968,7 @@ async function getAllAssets(client, parentKey, batchSize = 100) {
2521
2968
  const actualItems = items.items ?? [];
2522
2969
  const pageCount = Math.ceil(totalItemCount / pageSize);
2523
2970
  for (let pageNr = 1; pageNr < pageCount; pageNr++) {
2524
- const pageData = await client.preview2ContentListAssets({ path: { key: parentKey }, query: { pageIndex: pageNr, pageSize } }).catch((e) => {
2971
+ const pageData = await client.contentListAssets({ path: { key: parentKey }, query: { pageIndex: pageNr, pageSize } }).catch((e) => {
2525
2972
  if (e.status == 404) {
2526
2973
  return {
2527
2974
  items: [],
@@ -2536,7 +2983,7 @@ async function getAllAssets(client, parentKey, batchSize = 100) {
2536
2983
  return actualItems;
2537
2984
  }
2538
2985
  async function getAllItems(client, parentKey, batchSize = 100) {
2539
- const items = await client.preview2ContentListItems({ path: { key: parentKey }, query: { pageIndex: 0, pageSize: batchSize } }).catch((e) => {
2986
+ const items = await client.contentListItems({ path: { key: parentKey }, query: { pageIndex: 0, pageSize: batchSize } }).catch((e) => {
2540
2987
  if (e.status == 404) {
2541
2988
  return {
2542
2989
  items: [],
@@ -2551,7 +2998,7 @@ async function getAllItems(client, parentKey, batchSize = 100) {
2551
2998
  const actualItems = items.items ?? [];
2552
2999
  const pageCount = Math.ceil(totalItemCount / pageSize);
2553
3000
  for (let pageNr = 1; pageNr < pageCount; pageNr++) {
2554
- const pageData = await client.preview2ContentListItems({ path: { key: parentKey }, query: { pageIndex: pageNr, pageSize } }).catch((e) => {
3001
+ const pageData = await client.contentListItems({ path: { key: parentKey }, query: { pageIndex: pageNr, pageSize } }).catch((e) => {
2555
3002
  if (e.status == 404) {
2556
3003
  return {
2557
3004
  items: [],
@@ -2584,20 +3031,23 @@ const commands = [
2584
3031
  StylesListCommand,
2585
3032
  StylesPullCommand,
2586
3033
  StylesPushCommand,
3034
+ StylesDeleteCommand,
2587
3035
  TypesPullCommand,
2588
- TypesPushCommand
3036
+ TypesPushCommand,
3037
+ MigrateCommand
2589
3038
  ];
2590
3039
 
2591
3040
  async function main() {
2592
- const envFiles = prepare();
2593
- const app = createOptiCmsApp(APP.name, APP.version, undefined, envFiles);
3041
+ const projectDir = getProjectDir();
3042
+ const envFiles = prepare(projectDir);
3043
+ const app = createOptiCmsApp(APP.name, APP.version, undefined, envFiles, projectDir);
2594
3044
  app.command(commands);
2595
3045
  try {
2596
3046
  await app.parse(process.argv.slice(2));
2597
3047
  }
2598
3048
  catch {
2599
3049
  //We're ignoring error here, as yargs will already generate the "nice output" for it
2600
- //console.log('Caught error')
3050
+ //console.log ('Caught error')
2601
3051
  }
2602
3052
  }
2603
3053
  main();