@remkoj/optimizely-cms-cli 6.0.0-pre9 → 6.0.0-rc.3

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,61 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Developer Utitility providing helpers for common tasks to building a
4
+ * frontend application that uses Optimizely CMS/Graph as content repository.
5
+ *
6
+ * License: Apache 2
7
+ * Copyright (c) 2023-2026 - Remko Jantzen
8
+ */
9
+
1
10
  import { globSync, glob, globIterate } from 'glob';
2
11
  import { config } from 'dotenv';
3
12
  import { expand } from 'dotenv-expand';
4
- import { readPartialEnvConfig, createClient, OptiCmsVersion, ContentRoots } from '@remkoj/optimizely-cms-api';
5
- import yargs from 'yargs';
6
- import chalk from 'chalk';
7
13
  import path from 'node:path';
8
14
  import fs from 'node:fs';
15
+ import { readPartialEnvConfig, createClient, ContentRoots } from '@remkoj/optimizely-cms-api';
16
+ import yargs from 'yargs';
17
+ import chalk from 'chalk';
9
18
  import figures from 'figures';
10
19
  import Table from 'cli-table3';
11
- import fsAsync from 'node:fs/promises';
20
+ import slugify from '@sindresorhus/slugify';
21
+ import { diff } from 'deep-object-diff';
22
+ import fs$1 from 'node:fs/promises';
12
23
  import { input, select, confirm } from '@inquirer/prompts';
13
24
  import createDeepMerge from '@fastify/deepmerge';
14
25
  import { Ajv } from 'ajv';
15
26
  import addFormats from 'ajv-formats';
16
- import GraphQLGen from '@remkoj/optimizely-graph-functions/contenttype-loader';
27
+ import { DocumentGenerator, PropertyCollisionTracker } from '@remkoj/optimizely-graph-functions/generate';
17
28
  import deepEqual from 'fast-deep-equal';
18
29
 
30
+ function getProjectDir() {
31
+ let testPath = process.cwd();
32
+ let hasPackageJson = false;
33
+ do {
34
+ hasPackageJson = fs.statSync(path.join(testPath, 'package.json'), { throwIfNoEntry: false })?.isFile() ?? false;
35
+ if (!hasPackageJson)
36
+ testPath = path.normalize(path.join(testPath, '..'));
37
+ } while (!hasPackageJson && testPath.length > 2);
38
+ if (!hasPackageJson) {
39
+ throw new Error('No package.json found!');
40
+ }
41
+ return testPath;
42
+ }
19
43
  /**
20
44
  * Prepare the application context, by parsing the .env files in the main
21
45
  * application directory.
22
46
  *
23
47
  * @returns A string array with the files processed
24
48
  */
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`));
49
+ function prepare(pDir) {
50
+ const projectDir = pDir ?? getProjectDir();
51
+ const envFiles = globSync(".env*", { cwd: projectDir })
52
+ .sort((a, b) => b.length - a.length)
53
+ .filter(n => n == ".env" || n == ".env.local" || (process.env.NODE_ENV && n == `.env.${process.env.NODE_ENV}.local`));
28
54
  expand(config({ path: envFiles, debug: false, quiet: true }));
29
55
  return envFiles;
30
56
  }
31
57
 
32
- function createOptiCmsApp(scriptName, version, epilogue, envFiles) {
58
+ function createOptiCmsApp(scriptName, version, epilogue, envFiles, projectDir) {
33
59
  if (envFiles) {
34
60
  process.stdout.write(chalk.bold(`✅ Loaded environment files:`) + "\n");
35
61
  envFiles.forEach(envFile => {
@@ -45,11 +71,12 @@ function createOptiCmsApp(scriptName, version, epilogue, envFiles) {
45
71
  console.error(chalk.redBright(`${chalk.bold("[ERROR]:")} Error processing environment variables: ${e.message}`));
46
72
  process.exit(1);
47
73
  }
74
+ const defaultPath = projectDir ?? process.cwd();
48
75
  return yargs(process.argv)
49
76
  .scriptName(scriptName)
50
77
  .version(version)
51
78
  .usage('$0 <cmd> [args]')
52
- .option("path", { alias: "p", description: "Application root folder", string: true, type: "string", demandOption: false, default: process.cwd() })
79
+ .option("path", { alias: "p", description: "Application root folder", string: true, type: "string", demandOption: false, default: defaultPath })
53
80
  .option("components", { alias: "c", description: "Path to components folder", string: true, type: "string", demandOption: false, default: "./src/components/cms" })
54
81
  .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
82
  .option("client_id", { alias: "ci", description: "API Client ID", string: true, type: "string", demandOption: isDemanded(config.clientId), default: config.clientId })
@@ -133,84 +160,6 @@ function getCmsIntegrationApiOptions(args) {
133
160
  return parseArgs(args)._config;
134
161
  }
135
162
 
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
163
  /**
215
164
  * This file contains tools that allow using the project that we're targeting
216
165
  */
@@ -223,8 +172,12 @@ function isNotNullOrUndefined(i) {
223
172
  * @returns
224
173
  */
225
174
  function getContentTypePaths(contentType, basePath, createFolder = false, debug = false) {
226
- const baseTypeSlug = typeToSlug(contentType.baseType);
227
- const typePath = path.join(basePath, baseTypeSlug, contentType.key);
175
+ const baseTypeSlug = keyToSlug(contentType.baseType, {
176
+ defaultKey: 'global',
177
+ stripLeadingUnderscore: true
178
+ });
179
+ const typeSlug = keyToSlug(contentType.key);
180
+ const typePath = path.join(basePath, baseTypeSlug, typeSlug);
228
181
  if (createFolder) {
229
182
  if (!fs.existsSync(typePath)) {
230
183
  fs.mkdirSync(typePath, { recursive: true });
@@ -235,10 +188,10 @@ function getContentTypePaths(contentType, basePath, createFolder = false, debug
235
188
  if (!fs.statSync(typePath).isDirectory())
236
189
  throw new Error(`The folder ${typePath} for Content-Type ${contentType.key} exists, but is not a directory!`);
237
190
  }
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`);
191
+ const typeFile = path.join(typePath, `${typeSlug}.opti-type.json`);
192
+ const fragmentFile = path.join(typePath, `${typeSlug}.${baseTypeSlug}.graphql`);
193
+ const propertyFragmentFile = path.join(typePath, `${typeSlug}.property.graphql`);
194
+ const queryFile = path.join(typePath, `${typeSlug}.query.graphql`);
242
195
  const componentFile = path.join(typePath, `index.tsx`);
243
196
  return {
244
197
  type: contentType.key,
@@ -251,16 +204,187 @@ function getContentTypePaths(contentType, basePath, createFolder = false, debug
251
204
  queryFile
252
205
  };
253
206
  }
207
+ function getStyleFilePathsSync(displayTemplate, contentBaseType = '_Component', basePath = './src/components/cms', createFolder = false) {
208
+ const displayTemplateType = getDisplayTemplateType(displayTemplate);
209
+ let target;
210
+ let groupPath;
211
+ switch (displayTemplateType) {
212
+ case 'base':
213
+ groupPath = path.join(keyToSlug(displayTemplate.baseType, { stripLeadingUnderscore: true }), 'styles');
214
+ target = displayTemplate.baseType;
215
+ break;
216
+ case 'node':
217
+ groupPath = path.join('nodes', keyToSlug(displayTemplate.nodeType));
218
+ target = displayTemplate.nodeType;
219
+ break;
220
+ case 'component':
221
+ {
222
+ const baseType = contentBaseType;
223
+ target = displayTemplate.contentType;
224
+ groupPath = path.join(keyToSlug(baseType, { stripLeadingUnderscore: true }), keyToSlug(displayTemplate.contentType, { stripLeadingGroup: true }));
225
+ break;
226
+ }
227
+ default:
228
+ throw new Error(`Unsupported DisplayTemplate target for ${displayTemplate.key}`);
229
+ }
230
+ const displayTemplateKeySlug = keyToSlug(displayTemplate.key ?? 'displayTemplate', { stripLeadingGroup: true });
231
+ const groupFolder = path.join(basePath, groupPath);
232
+ const styleFolder = displayTemplateType === 'component' ? groupFolder : path.join(groupFolder, displayTemplateKeySlug);
233
+ if (createFolder && !fs.existsSync(styleFolder))
234
+ fs.mkdirSync(styleFolder, { recursive: true });
235
+ return {
236
+ styleFile: path.join(styleFolder, displayTemplateKeySlug + '.opti-style.json'),
237
+ helperFile: path.join(groupFolder, 'displayTemplates.ts'),
238
+ identifier: `${displayTemplateType}/${target}`,
239
+ helperFolder: groupFolder,
240
+ styleFolder
241
+ };
242
+ }
243
+ async function getStyleFilePaths(definition, opts) {
244
+ let defintionBaseType;
245
+ if (definition.contentType && !opts.contentBaseType && opts.client) {
246
+ const contentType = await opts.client.contentTypesGet({ path: { key: definition.contentType } }).catch(() => undefined);
247
+ defintionBaseType = contentType?.baseType;
248
+ }
249
+ else {
250
+ defintionBaseType = opts.contentBaseType;
251
+ }
252
+ return getStyleFilePathsSync(definition, defintionBaseType, opts.basePath, opts.createFolder);
253
+ }
254
254
  /**
255
+ * Simple logic to create path slugs for storing ContentType related files on
256
+ * disk.
255
257
  *
256
- * @param typeKey
258
+ * @param typeKey
259
+ * @param stripLeadingUnderscore
257
260
  * @returns
258
261
  */
259
- function typeToSlug(typeKey) {
260
- let typeKeySlug = typeKey.toLowerCase();
261
- if (typeKeySlug.startsWith('_'))
262
- typeKeySlug = typeKeySlug.substring(1);
263
- return typeKeySlug;
262
+ function keyToSlug(typeKey, options = {}) {
263
+ // Destruct the configuration into the parts we need
264
+ const { stripLeadingGroup = true, defaultKey = 'unknown', stripLeadingUnderscore = false, ...slugifyBaseConfig } = options;
265
+ const slugifyConfig = {
266
+ ...slugifyBaseConfig,
267
+ preserveLeadingUnderscore: !stripLeadingUnderscore
268
+ };
269
+ // First make sure we have a valid input type
270
+ if (!(typeof typeKey === 'string' || typeKey === undefined || typeKey === null))
271
+ throw new Error(`Invalid typeKey provided, expected an optional string, received a value of type ${typeof typeKey}`);
272
+ // Then, take the prefix out, if any and required
273
+ const toSlugify = stripLeadingGroup && typeKey?.indexOf(":") >= 0 ? typeKey.split(":", 2).at(1) : typeKey;
274
+ // Finally slugify the result
275
+ return slugify(toSlugify ?? defaultKey, slugifyConfig);
276
+ }
277
+ function getDisplayTemplateType(displayTemplate) {
278
+ const templateType = displayTemplate.baseType ? 'base' : displayTemplate.nodeType ? 'node' : displayTemplate.contentType ? 'component' : 'unknown';
279
+ return templateType;
280
+ }
281
+
282
+ /**
283
+ * Test if the value must be included in a set, by defining allowed values and blocked values. A value
284
+ * is considered be eligible to be included when it's both allowed an not disallowed.
285
+ *
286
+ * @param value The value to test
287
+ * @param allow The list of allowed values, when not provided, or and empty array, all
288
+ * values will be allowed.
289
+ * @param disallow The list of explicitly disallowed values, yielding an "all allowed but
290
+ * these" operation
291
+ * @param compareSlugified When set to `true`, and after test with `includes()` on the allow and
292
+ * disallow did not yield a match, will try to match by converting the
293
+ * values to string (using the `.toString()` method), then slugify them with
294
+ * the `keyToSlug` method and then comparing the outcomes.
295
+ * @param slugifyOptions The options to provide to the `keyToSlug` method, this will be ignored
296
+ * unless you set the `compareSlugified` parameter to `true`
297
+ * @returns If the value should be included in the result, based upon the `allow` and
298
+ * `disallow` configuration.
299
+ *
300
+ * @see {@link keyToSlug}
301
+ */
302
+ function shouldInclude(value, allow, disallow, compareSlugified = false, slugifyOptions) {
303
+ // Do not include null or undefined values
304
+ if (value === null || value === undefined)
305
+ return false;
306
+ // Item is allowed when either allow is not set, an empty array or has the value
307
+ const isAllowed = !Array.isArray(allow) || allow.length === 0 || allow.includes(value) || (compareSlugified && allow.some(av => keyToSlug(av.toString(), slugifyOptions) === keyToSlug(value.toString(), slugifyOptions)));
308
+ // Item is disallowed when and the array is set and includes the value
309
+ const isDisallowed = Array.isArray(disallow) && (disallow.includes(value) || (compareSlugified && disallow.some(av => keyToSlug(av.toString(), slugifyOptions) === keyToSlug(value.toString(), slugifyOptions))));
310
+ // Return the outcome
311
+ return isAllowed && !isDisallowed;
312
+ }
313
+ /**
314
+ * Test if the provided content type is a contract
315
+ *
316
+ * @param contentType The ContentType to test
317
+ * @returns `true` if the ContentType is a contract, `false` otherwise
318
+ * @see {@link IntegrationApi.ContentType}
319
+ */
320
+ function isContract(contentType) {
321
+ if (typeof contentType !== 'object' || contentType === null)
322
+ return false;
323
+ return contentType.isContract || contentType.source === 'globalcontract';
324
+ }
325
+ /**
326
+ * Test if the provided content type comes from the Graph content source (e.g. external content)
327
+ *
328
+ * @param contentType The ContentType to test
329
+ * @returns `true` if the ContentType is a graph type, `false` otherwise
330
+ * @see {@link IntegrationApi.ContentType}
331
+ */
332
+ function isGraphType(contentType) {
333
+ if (typeof contentType !== 'object' || contentType === null)
334
+ return false;
335
+ return (contentType.key && typeof (contentType.key) === 'string' && contentType.key.startsWith('graph:')) || contentType.source === 'graph';
336
+ }
337
+ /**
338
+ * Test if the provided content type is a folder, which is a structural element for editors, but
339
+ * does not represent anything significant when working with ContentType
340
+ *
341
+ * @param contentType The ContentType to test
342
+ * @returns `true` if the ContentType is a folder, `false` otherwise
343
+ * @see {@link IntegrationApi.ContentType}
344
+ */
345
+ function isFolder(contentType) {
346
+ if (typeof contentType !== 'object' || contentType === null)
347
+ return false;
348
+ return contentType.baseType === '_folder';
349
+ }
350
+ /**
351
+ * Test if the provided content type is a system type
352
+ *
353
+ * @param contentType The ContentType to test
354
+ * @returns `true` if the ContentType is a system type, `false` otherwise
355
+ * @see {@link IntegrationApi.ContentType}
356
+ */
357
+ function isSystemType(contentType) {
358
+ if (typeof contentType !== 'object' || contentType === null)
359
+ return false;
360
+ return contentType.source === 'system';
361
+ }
362
+ /**
363
+ * Type guard that returns `true` when `toTest` is a non-empty array.
364
+ *
365
+ * @param toTest The value to inspect.
366
+ * @returns `true` when `toTest` is an `Array` with at least one element.
367
+ */
368
+ function isNonEmptyArray(toTest) {
369
+ return Array.isArray(toTest) && toTest.length > 0;
370
+ }
371
+ /**
372
+ * Type guard that returns `true` when `toTest` is `null`, `undefined`, or an empty array.
373
+ *
374
+ * @param toTest The value to inspect.
375
+ * @returns `true` when `toTest` is `null` or an `Array` with no elements.
376
+ */
377
+ function isEmptyArray(toTest) {
378
+ return toTest === null || !Array.isArray(toTest) || toTest.length === 0;
379
+ }
380
+ /**
381
+ * Type guard that returns `true` when `toTest` is neither `null` nor `undefined`.
382
+ *
383
+ * @param toTest The value to inspect.
384
+ * @returns `true` when `toTest` is a defined, non-null value of type `T`.
385
+ */
386
+ function isDefined(toTest) {
387
+ return toTest !== null && toTest !== undefined;
264
388
  }
265
389
 
266
390
  const ContentTypesArgsDefaults = {
@@ -283,28 +407,41 @@ async function getContentTypes(client, args, pageSize = 25, allowSystem = false,
283
407
  const allContentTypes = [];
284
408
  const filteredContentTypes = [];
285
409
  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:')) {
410
+ // Skip contracts by default as they're abstract classes and cannot be used to directly store data.
411
+ if (!all && isContract(contentType)) {
288
412
  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`));
413
+ 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`));
414
+ continue;
415
+ }
416
+ // Skip content types mapped against Graph data, these should be used with their source type in Graph,
417
+ // not the reference in CMS
418
+ if (!all && isGraphType(contentType)) {
419
+ if (cfg.debug)
420
+ 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`));
421
+ continue;
422
+ }
423
+ // Skip folder types as these are non-data carrying types in the instance.
424
+ if (!all && isFolder(contentType)) {
425
+ if (cfg.debug)
426
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it is a folder, use --all to include\n`));
290
427
  continue;
291
428
  }
292
429
  // Build the unfiltered array
293
430
  allContentTypes.push(contentType);
294
431
  // Skip based upon base type filters
295
- if (!shouldInclude(typeToSlug(contentType.baseType), baseTypes.map(x => typeToSlug(x)), excludeBaseTypes.map(x => typeToSlug(x)))) {
432
+ if (!shouldInclude(contentType.baseType, baseTypes, excludeBaseTypes, true)) {
296
433
  if (cfg.debug)
297
434
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it has a restricted base type: ${contentType.baseType}\n`));
298
435
  continue;
299
436
  }
300
437
  // Skip based upon type filters
301
- if (!shouldInclude(contentType.key, types, excludeTypes)) {
438
+ if (!shouldInclude(contentType.key, types, excludeTypes, true)) {
302
439
  if (cfg.debug)
303
- process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it has a restricted type: ${contentType.key}\n`));
440
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${contentType.key} as it is a restricted type (${types.join(', ')})(${excludeTypes.join(', ')})\n`));
304
441
  continue;
305
442
  }
306
443
  // Skip based upon system filter
307
- if (contentType.source == 'system' && !allowSystem) {
444
+ if (!allowSystem && isSystemType(contentType)) {
308
445
  if (cfg.debug)
309
446
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Content-Type ${contentType.key} due to it being a system type\n`));
310
447
  continue;
@@ -322,13 +459,6 @@ async function getContentTypes(client, args, pageSize = 25, allowSystem = false,
322
459
  process.stdout.write(chalk.gray(`${figures.arrowRight} Applied content type filters, reduced from ${allContentTypes.length} to ${filteredContentTypes.length} items\n`));
323
460
  return { all: allContentTypes, contentTypes: filteredContentTypes };
324
461
  }
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
462
  /**
333
463
  * Retrieve all content types as an Async Generator, allowing processing of entries whilest they are being loaded from the CMS instance.
334
464
  *
@@ -340,25 +470,28 @@ async function* getAllContentTypes(client, debug = false, pageSize = 25) {
340
470
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Pulling Content Types from Optimizely CMS\n`));
341
471
  let requestPageSize = pageSize;
342
472
  let requestPageIndex = 0;
343
- let totalItemCount = 0;
344
- let totalPages = 0;
473
+ let totalItemCount;
474
+ let totalPages;
345
475
  do {
346
- const resultsPage = await client.contentTypesList({ query: { pageIndex: requestPageIndex, pageSize: requestPageSize } }).catch((_) => {
476
+ const resultsPage = await client.contentTypesList({ query: { pageIndex: requestPageIndex, pageSize: requestPageSize } }).catch(() => {
347
477
  return {
348
478
  items: [],
349
479
  totalItemCount: 0,
480
+ totalCount: 0,
350
481
  pageIndex: requestPageIndex,
351
482
  pageSize: requestPageSize
352
483
  };
353
484
  });
485
+ console.log(resultsPage);
354
486
  // Calculate fields for next page
355
- totalItemCount = resultsPage.totalItemCount ?? 0;
487
+ //@ts-expect-error There's a difference between the SaaS & PaaS API, hence we're ignoring the next line
488
+ totalItemCount = resultsPage.totalItemCount ?? resultsPage.totalCount ?? 0;
356
489
  requestPageSize = resultsPage.pageSize;
357
490
  requestPageIndex = resultsPage.pageIndex + 1;
358
491
  totalPages = Math.ceil(totalItemCount / requestPageSize);
359
492
  // Debug output
360
493
  if (debug)
361
- process.stdout.write(chalk.gray(`${figures.arrowRight} Fetched contentTypes page ${requestPageIndex} of ${totalPages} (${requestPageSize} items per page)\n`));
494
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Fetched contentTypes page ${requestPageIndex} of ${totalPages} (${requestPageSize} items per page, ${totalItemCount} items available)\n`));
362
495
  // Yield items
363
496
  for (const contentType of (resultsPage.items ?? [])) {
364
497
  yield contentType;
@@ -376,50 +509,60 @@ const stylesBuilder = yargs => {
376
509
  return newArgs;
377
510
  };
378
511
  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);
512
+ const { _config: cfg, excludeBaseTypes: disallowBaseTypes, excludeTypes: disallowTypes, excludeNodeTypes: disallowNodeTypes, excludeTemplates: disallowTemplates, baseTypes: allowBaseTypes, types: allowTypes, nodes: allowNodeTypes, templates: allowTemplates, templateTypes: baseAllowTemplateTypes } = parseArgs(args);
382
513
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Pulling Style-Definitions from Optimizely CMS\n`));
514
+ const allowTemplateTypes = [];
515
+ if (!Array.isArray(baseAllowTemplateTypes) || baseAllowTemplateTypes.length === 0) {
516
+ if (isNonEmptyArray(allowBaseTypes)) {
517
+ console.log('BT', allowBaseTypes);
518
+ if (cfg.debug)
519
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Base picking filter is active, adjusting template type filter to pick templates targeting a base type\n`));
520
+ allowTemplateTypes.push('base');
521
+ }
522
+ if (isNonEmptyArray(allowNodeTypes)) {
523
+ if (cfg.debug)
524
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Node type filter active, adjusting template type filter\n`));
525
+ allowTemplateTypes.push('node');
526
+ }
527
+ if (isNonEmptyArray(allowTypes)) {
528
+ if (cfg.debug)
529
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Component type filter active, adjusting template type filter\n`));
530
+ allowTemplateTypes.push('component');
531
+ }
532
+ }
533
+ else {
534
+ allowTemplateTypes.push(...baseAllowTemplateTypes);
535
+ }
383
536
  const allDisplayTemplates = [];
384
537
  const filteredDisplayTemplates = [];
385
538
  for await (const displayTemplate of getAllStyles(client, cfg.debug, pageSize)) {
386
539
  allDisplayTemplates.push(displayTemplate);
387
- const templateType = displayTemplate.baseType ? 'base' : displayTemplate.nodeType ? 'node' : displayTemplate.contentType ? 'component' : 'unknown';
388
- if (isExcluded(displayTemplate.key, excludeTemplates, templates)) {
540
+ const templateType = getDisplayTemplateType(displayTemplate);
541
+ if (!shouldInclude(displayTemplate.key, allowTemplates, disallowTemplates)) {
389
542
  if (cfg.debug)
390
543
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Style-Defintion ${displayTemplate.key} - Style defintion key filtering active\n`));
391
544
  continue;
392
545
  }
393
- if (isExcluded(templateType, [], templateTypes)) {
546
+ if (!shouldInclude(templateType, allowTemplateTypes)) {
394
547
  if (cfg.debug)
395
548
  process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping Style-Defintion ${displayTemplate.key} - Style type filtering is active\n`));
396
549
  continue;
397
550
  }
398
- if (displayTemplate.baseType && isExcluded(displayTemplate.baseType, excludeBaseTypes, baseTypes)) {
551
+ if (templateType === 'base' && !shouldInclude(displayTemplate.baseType, allowBaseTypes, disallowBaseTypes)) {
399
552
  if (cfg.debug)
400
553
  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
554
  continue;
402
555
  }
403
- if (displayTemplate.contentType && isExcluded(displayTemplate.contentType, excludeTypes, types)) {
556
+ if (templateType === 'component' && !shouldInclude(displayTemplate.contentType, allowTypes, disallowTypes)) {
404
557
  if (cfg.debug)
405
558
  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
559
  continue;
407
560
  }
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)) {
561
+ if (templateType === 'node' && !shouldInclude(displayTemplate.nodeType, allowNodeTypes, disallowNodeTypes)) {
414
562
  if (cfg.debug)
415
563
  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
564
  continue;
417
565
  }
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
566
  filteredDisplayTemplates.push(displayTemplate);
424
567
  }
425
568
  if (cfg.debug)
@@ -430,14 +573,12 @@ async function getStyles(client, args, pageSize = 25) {
430
573
  };
431
574
  }
432
575
  async function* getAllStyles(client, debug = false, pageSize = 5) {
433
- if (client.runtimeCmsVersion == OptiCmsVersion.CMS12)
434
- return;
435
576
  let requestPageSize = pageSize;
436
577
  let requestPageIndex = 0;
437
- let totalItemCount = 0;
438
- let totalPages = 0;
578
+ let totalItemCount;
579
+ let totalPages;
439
580
  do {
440
- const resultsPage = await client.displayTemplatesList({ query: { pageIndex: requestPageIndex, pageSize: requestPageSize } }).catch((_) => {
581
+ const resultsPage = await client.displayTemplatesList({ query: { pageIndex: requestPageIndex, pageSize: requestPageSize } }).catch(() => {
441
582
  return {
442
583
  items: [],
443
584
  totalItemCount: 0,
@@ -446,39 +587,298 @@ async function* getAllStyles(client, debug = false, pageSize = 5) {
446
587
  };
447
588
  });
448
589
  // Calculate fields for next page
449
- totalItemCount = resultsPage.totalItemCount ?? 0;
590
+ //@ts-expect-error Difference between PaaS & SaaS
591
+ totalItemCount = resultsPage.totalItemCount ?? resultsPage.totalCount ?? 0;
450
592
  requestPageSize = resultsPage.pageSize;
451
593
  requestPageIndex = resultsPage.pageIndex + 1;
452
594
  totalPages = Math.ceil(totalItemCount / requestPageSize);
453
595
  // Debug output
454
596
  if (debug)
455
- process.stdout.write(chalk.gray(`${figures.arrowRight} Fetched displayTemplates page ${requestPageIndex} of ${totalPages} (${requestPageSize} items per page)\n`));
597
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Fetched displayTemplates page ${requestPageIndex} of ${totalPages} (${requestPageSize} items per page, ${totalItemCount} items available)\n`));
456
598
  // Yield items
457
599
  for (const displayTemplate of (resultsPage.items ?? [])) {
458
600
  yield displayTemplate;
459
601
  }
460
602
  } while (requestPageIndex < totalPages);
461
603
  }
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`;
604
+ class TypeFilesList extends Map {
605
+ getDisplayTemplateByKey(displayTemplateKey) {
606
+ for (const groupKey of this.keys()) {
607
+ const templates = this.get(groupKey)?.templates || [];
608
+ const displayTemplate = templates.find(x => x.key === displayTemplateKey);
609
+ if (displayTemplate)
610
+ return displayTemplate.data;
611
+ }
612
+ return undefined;
613
+ }
614
+ getDisplayTemplatePathsByKey(displayTemplateKey) {
615
+ for (const identifier of this.keys()) {
616
+ const groupInfo = this.get(identifier);
617
+ const templates = groupInfo?.templates || [];
618
+ const helperFile = groupInfo?.filePath;
619
+ const helperFolder = groupInfo?.fileFolder;
620
+ const info = templates.find(x => x.key === displayTemplateKey);
621
+ if (info)
622
+ return {
623
+ styleFile: info.file,
624
+ styleFolder: info.folder,
625
+ identifier,
626
+ helperFile,
627
+ helperFolder
628
+ };
629
+ }
630
+ return undefined;
631
+ }
632
+ }
633
+ async function toTypeFilesList(displayTemplates, client, basePath) {
634
+ if (isNonEmptyArray(displayTemplates)) {
635
+ // Get all content types we need to get the type file lists
636
+ const targetContentTypes = (await Promise.allSettled(displayTemplates.map(async (displayTemplate) => {
637
+ if (!displayTemplate.contentType)
638
+ return undefined;
639
+ return client.contentTypesGet({ path: { key: displayTemplate.contentType } });
640
+ }))).map(x => x.status == 'fulfilled' ? x.value : undefined).filter(isDefined);
641
+ // Simple helper to get the base type from the list
642
+ function getBaseTypeOf(contentTypeKey) {
643
+ if (!contentTypeKey)
644
+ return undefined;
645
+ return targetContentTypes.find(x => x.key === contentTypeKey)?.baseType;
646
+ }
647
+ // Now reduce the list into the Map we need
648
+ return displayTemplates.reduce((aggregator, displayTemplate) => {
649
+ const contentTypeBaseType = getBaseTypeOf(displayTemplate.contentType);
650
+ const { identifier, helperFile, helperFolder, styleFile, styleFolder } = getStyleFilePathsSync(displayTemplate, contentTypeBaseType, basePath, true);
651
+ const info = aggregator.get(identifier) ?? { filePath: helperFile, fileFolder: helperFolder, templates: [] };
652
+ info.templates.push({ key: displayTemplate.key, file: styleFile, data: displayTemplate, folder: styleFolder });
653
+ aggregator.set(identifier, info);
654
+ return aggregator;
655
+ }, new TypeFilesList());
480
656
  }
481
- throw new Error(`Unable to resolve the target for the DisplayTemplate: ${definition.key}`);
657
+ return new TypeFilesList();
658
+ }
659
+
660
+ function normalizeMergePatch(value, omitKeys, requiredKeys = [], requiredSource, atomicKeys = []) {
661
+ if (value === undefined || value === null)
662
+ return null;
663
+ if (Array.isArray(value))
664
+ return value.map(item => normalizeMergePatch(item, omitKeys));
665
+ if (typeof value !== 'object')
666
+ return value;
667
+ const output = Object.entries(value).reduce((acc, [key, entry]) => {
668
+ if (!omitKeys.includes(key)) {
669
+ if (atomicKeys.includes(key) && requiredSource !== undefined) {
670
+ // Include the full value from newValue verbatim — do not recurse into the diff
671
+ acc[key] = requiredSource[key];
672
+ }
673
+ else {
674
+ acc[key] = normalizeMergePatch(entry, omitKeys);
675
+ }
676
+ }
677
+ return acc;
678
+ }, {});
679
+ const withRequiredKeys = requiredKeys.reduce((acc, key) => {
680
+ if (requiredSource && !Object.keys(acc).includes(key))
681
+ acc[key] = requiredSource[key];
682
+ return acc;
683
+ }, output);
684
+ return withRequiredKeys;
685
+ }
686
+ /**
687
+ * Computes a JSON Merge Patch (RFC 7396) that transforms `currentValue` into `newValue`.
688
+ *
689
+ * The resulting patch can be sent directly as the request body of an `application/merge-patch+json`
690
+ * request. Changed and added properties are included with their new value; removed properties are
691
+ * included with a `null` value (as required by RFC 7396); unchanged properties are omitted entirely.
692
+ *
693
+ * @see https://datatracker.ietf.org/doc/html/rfc7396
694
+ *
695
+ * @example Basic usage
696
+ * ```ts
697
+ * const current = { name: 'Alice', age: 30, role: 'user' }
698
+ * const updated = { name: 'Alice', age: 31 }
699
+ *
700
+ * generatePatch(current, updated)
701
+ * // => { age: 31, role: null }
702
+ * // `age` is replaced, `role` is removed (null), `name` is unchanged (omitted)
703
+ * ```
704
+ *
705
+ * @example Excluding keys from the patch
706
+ * ```ts
707
+ * const current = { id: '123', name: 'Alice', version: 1 }
708
+ * const updated = { id: '123', name: 'Bob', version: 2 }
709
+ *
710
+ * generatePatch(current, updated, { readonlyFields: ['id', 'version'] })
711
+ * // => { name: 'Bob' }
712
+ * // `id` and `version` are excluded even though they are present in the diff
713
+ * ```
714
+ *
715
+ * @example Always include required fields
716
+ * ```ts
717
+ * const current = { id: '123', name: 'Alice', version: 1 }
718
+ * const updated = { id: '123', name: 'Alice', version: 2 }
719
+ *
720
+ * generatePatch(current, updated, { requiredFields: ['id'] })
721
+ * // => { id: '123', version: 2 }
722
+ * // `id` is included even though it did not change
723
+ * ```
724
+ *
725
+ * @example Nested objects
726
+ * ```ts
727
+ * const current = { address: { city: 'Amsterdam', zip: '1000AA' } }
728
+ * const updated = { address: { city: 'Utrecht' } }
729
+ *
730
+ * generatePatch(current, updated)
731
+ * // => { address: { city: 'Utrecht', zip: null } }
732
+ * ```
733
+ *
734
+ * @param currentValue - The current state of the resource.
735
+ * @param newValue - The desired state of the resource.
736
+ * @param options - Optional configuration.
737
+ * @param options.readonlyFields - Keys that should never appear in the generated patch,
738
+ * regardless of whether they changed.
739
+ * @param options.requiredFields - Keys that should always appear in the generated patch,
740
+ * using values from `newValue`.
741
+ * @param options.atomicFields - Keys whose values are always included verbatim when they
742
+ * change, rather than being recursively patched. Use for
743
+ * record- or dictionary-typed properties (e.g.
744
+ * `{ [key: string]: SomeObject }`) and arrays of objects.
745
+ * @returns A {@link MergePatch} object ready to be serialised as an `application/merge-patch+json` body.
746
+ */
747
+ function generatePatch(currentValue, newValue, options = {}) {
748
+ const requiredKeys = options.requiredFields ?? [];
749
+ const omitKeys = (options.readonlyFields ?? []).filter(key => !requiredKeys.includes(key));
750
+ const atomicKeys = (options.atomicFields ?? []);
751
+ const patch = diff(currentValue, newValue);
752
+ return normalizeMergePatch(patch, omitKeys, requiredKeys, newValue, atomicKeys);
753
+ }
754
+ /**
755
+ * Extracts all field paths from a {@link MergePatch} as dot-separated strings.
756
+ * Nested objects are flattened recursively; leaf values (including `null`) produce a path entry.
757
+ *
758
+ * @example
759
+ * ```ts
760
+ * getPatchFields({ a: { b: 0 }, c: 'value' })
761
+ * // => ['a.b', 'c']
762
+ * ```
763
+ *
764
+ * @param patch - The merge patch to extract fields from.
765
+ * @param prefix - Internal prefix used during recursion; omit when calling directly.
766
+ * @returns An array of dot-separated field path strings.
767
+ */
768
+ function getPatchFields(patch, prefix = '') {
769
+ return Object.entries(patch).flatMap(([key, value]) => {
770
+ const path = prefix ? `${prefix}.${key}` : key;
771
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
772
+ ? getPatchFields(value, path)
773
+ : [path];
774
+ });
775
+ }
776
+
777
+ const StylesPushCommand = {
778
+ command: "styles:push",
779
+ describe: "Push Visual Builder style definitions into the CMS (create/replace)",
780
+ builder: (yargs) => {
781
+ yargs.option('excludeTemplates', { alias: 'e', description: "Exclude these templates", string: true, type: 'array', demandOption: false, default: [] });
782
+ yargs.option("templates", { alias: 't', description: "Select only these templates", string: true, type: 'array', demandOption: false, default: [] });
783
+ return yargs;
784
+ },
785
+ handler: async (args) => {
786
+ const { _config: cfg, excludeTemplates, templates, ...opts } = parseArgs(args);
787
+ const client = createCmsClient(args);
788
+ const { styles: displayTemplates } = await getStyles(client, {
789
+ all: false,
790
+ baseTypes: [],
791
+ excludeBaseTypes: [],
792
+ excludeNodeTypes: [],
793
+ excludeTemplates: [],
794
+ excludeTypes: [],
795
+ nodes: [],
796
+ templates: [],
797
+ templateTypes: [],
798
+ types: [],
799
+ ...args
800
+ }, 50);
801
+ process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Pushing (create/replace) DisplayStyles into Optimizely CMS\n`));
802
+ const styleDefinitionFiles = await glob("./**/*.opti-style.json", {
803
+ cwd: opts.components
804
+ });
805
+ const results = (await Promise.allSettled(styleDefinitionFiles.map(async (styleDefinitionFile) => {
806
+ const filePath = path.normalize(path.join(opts.components, styleDefinitionFile));
807
+ const styleDefinition = tryReadJsonFile(filePath, cfg.debug);
808
+ const styleKey = styleDefinition.key;
809
+ if (!styleKey) {
810
+ 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`));
811
+ return undefined;
812
+ }
813
+ if (excludeTemplates.includes(styleKey))
814
+ return undefined; // Skip excluded styles
815
+ if (templates.length > 0 && !templates.includes(styleKey))
816
+ return undefined; // Only include defined styles, if any
817
+ if (cfg.debug)
818
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Pushing: ${styleKey}\n`));
819
+ // Try to fetch the current template
820
+ const currentTemplate = displayTemplates.find(dt => dt.key === styleKey);
821
+ // Create / Replace the current template
822
+ const newTemplate = await (currentTemplate ? (async () => {
823
+ const patch = generatePatch(currentTemplate, styleDefinition, {
824
+ readonlyFields: ['key', 'created', 'lastModified', 'createdBy', 'lastModifiedBy'],
825
+ atomicFields: ['settings']
826
+ });
827
+ if (!path || Object.entries(patch).length === 0)
828
+ return currentTemplate;
829
+ return client.displayTemplatesPatch({ path: { key: styleKey }, body: patch });
830
+ })() :
831
+ client.displayTemplatesCreate({ body: styleDefinition }));
832
+ const missedFields = newTemplate ? generatePatch(newTemplate, currentTemplate, {
833
+ readonlyFields: ['key', 'created', 'lastModified', 'createdBy', 'lastModifiedBy']
834
+ }) : undefined;
835
+ if (missedFields && Object.keys(missedFields).length > 0)
836
+ throw new Error(`The Display template ${styleKey} failed to update properties: ${getPatchFields(missedFields).join('; ')}`);
837
+ return newTemplate;
838
+ })));
839
+ const styles = new Table({
840
+ head: [
841
+ chalk.yellow(chalk.bold("Name")),
842
+ chalk.yellow(chalk.bold("Key")),
843
+ chalk.yellow(chalk.bold("Default")),
844
+ chalk.yellow(chalk.bold("Target"))
845
+ ],
846
+ colWidths: [31, 20, 9, 20],
847
+ colAligns: ["left", "left", "center", "left"]
848
+ });
849
+ results.forEach(result => {
850
+ if (result.status === 'fulfilled') {
851
+ if (isDefined(result.value)) {
852
+ const tpl = result.value;
853
+ styles.push([
854
+ tpl.displayName,
855
+ tpl.key,
856
+ tpl.isDefault ? figures.tick : figures.cross,
857
+ tpl.contentType ? `${tpl.contentType} (C)` : tpl.baseType ? `${tpl.baseType} (B)` : `${tpl.nodeType} (N)`
858
+ ]);
859
+ }
860
+ else {
861
+ process.stderr.write(`DisplayTemplate pushed without errors, but no result was returned.\n`);
862
+ }
863
+ }
864
+ else {
865
+ process.stderr.write(`Error processing DisplayTemplate: ${result.reason ?? "n"}\n`);
866
+ }
867
+ });
868
+ process.stdout.write(styles.toString() + "\n");
869
+ process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
870
+ }
871
+ };
872
+ function tryReadJsonFile(filePath, debug = false) {
873
+ try {
874
+ if (debug)
875
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Reading style definition from ${filePath}\n`));
876
+ return JSON.parse(fs.readFileSync(filePath, { encoding: 'utf-8' }));
877
+ }
878
+ catch {
879
+ process.stderr.write(chalk.redBright(`${chalk.bold(figures.cross)} Error while reading ${filePath}\n`));
880
+ }
881
+ return undefined;
482
882
  }
483
883
 
484
884
  const StylesListCommand = {
@@ -486,10 +886,6 @@ const StylesListCommand = {
486
886
  describe: "List Visual Builder style definitions from the CMS",
487
887
  handler: async (args) => {
488
888
  const client = createCmsClient(args);
489
- if (client.runtimeCmsVersion == OptiCmsVersion.CMS12) {
490
- process.stdout.write(chalk.gray(`${figures.cross} Styles are not supported on CMS12\n`));
491
- return;
492
- }
493
889
  const { all: results } = await getStyles(client, { ...args, excludeBaseTypes: [], excludeNodeTypes: [], excludeTemplates: [], excludeTypes: [], baseTypes: [], nodes: [], templates: [], types: [], templateTypes: [], all: false });
494
890
  const styles = new Table({
495
891
  head: [
@@ -514,6 +910,14 @@ const StylesListCommand = {
514
910
  }
515
911
  };
516
912
 
913
+ function ucFirst(current) {
914
+ if (typeof current != 'string')
915
+ throw new Error("Only strings can be transformed");
916
+ if (current == "")
917
+ return current;
918
+ return current[0]?.toUpperCase() + current.substring(1);
919
+ }
920
+
517
921
  const StylesPullCommand = {
518
922
  command: "styles:pull",
519
923
  describe: "Create Visual Builder style definitions from the CMS",
@@ -524,129 +928,64 @@ const StylesPullCommand = {
524
928
  return newYargs;
525
929
  },
526
930
  handler: async (args) => {
527
- const { _config: cfg, components: basePath, force, definitions } = parseArgs(args);
931
+ const { _config: cfg, components: basePath, force} = parseArgs(args);
528
932
  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
933
  const { styles: filteredResults } = await getStyles(client, args);
534
- //#region Create & Write opti-style.json files
535
934
  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
- }
562
- }
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));
935
+ const styleFiles = await toTypeFilesList(filteredResults, client, basePath);
936
+ const updatedTemplates = [];
937
+ for (const groupIdentifier of styleFiles.keys()) {
938
+ const displayTemplateGroup = styleFiles.get(groupIdentifier);
939
+ for (const { file: filePath, data: displayTemplate } of (displayTemplateGroup?.templates || [])) {
940
+ // Write JSON to disk
941
+ const updatedJson = await createDisplayTemplateFile(displayTemplate, filePath, force, cfg.debug);
942
+ if (updatedJson)
943
+ updatedTemplates.push(displayTemplate.key);
567
944
  }
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
945
+ // Write template to disk
946
+ void await createDisplayTemplateHelper(displayTemplateGroup, groupIdentifier, force, cfg.debug);
947
+ }
583
948
  process.stdout.write(chalk.green(chalk.bold(figures.tick + ` Created/updated style definitions for ${updatedTemplates.join(', ')}`)) + "\n");
584
949
  }
585
950
  };
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;
951
+ async function createDisplayTemplateFile(displayTemplate, filePath, force = false, debug = false) {
952
+ let updated = false;
953
+ // Build local style data
954
+ const outputTemplate = { ...displayTemplate };
955
+ if (outputTemplate.createdBy)
956
+ delete outputTemplate.createdBy;
957
+ if (outputTemplate.lastModifiedBy)
958
+ delete outputTemplate.lastModifiedBy;
959
+ if (outputTemplate.created)
960
+ delete outputTemplate.created;
961
+ if (outputTemplate.lastModified)
962
+ delete outputTemplate.lastModified;
963
+ // Write file to disk
964
+ if (fs.existsSync(filePath)) {
965
+ if (!force) {
966
+ if (debug)
967
+ process.stdout.write(chalk.gray(`${figures.cross} Skipping style file for ${displayTemplate.key} - File already exists\n`));
608
968
  }
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;
969
+ else {
970
+ if (debug) {
971
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Overwriting style file for ${displayTemplate.key}\n`));
972
+ }
973
+ fs.writeFileSync(filePath, JSON.stringify(outputTemplate, undefined, 2));
974
+ updated = true;
616
975
  }
617
- default:
618
- throw new Error("Unsupported display template type");
619
976
  }
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);
977
+ else {
978
+ if (debug)
979
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Creating style file for ${displayTemplate.key} in ${filePath}\n`));
980
+ fs.writeFileSync(filePath, JSON.stringify(outputTemplate, undefined, 2));
981
+ updated = true;
644
982
  }
983
+ return updated;
645
984
  }
646
985
  async function createDisplayTemplateHelper(typeFile, typeFileId, force = false, debug = false) {
647
986
  const prefix = '//not-modified - Remove this line when making change to prevent it from being updated by the CLI tools';
648
987
  const { filePath: typeFilePath, templates } = typeFile;
649
- const shouldWrite = await fsAsync.readFile(typeFilePath, { encoding: "utf-8" }).then(data => data.startsWith('//not-modified')).catch((e) => {
988
+ const shouldWrite = await fs$1.readFile(typeFilePath, { encoding: "utf-8" }).then(data => data.startsWith('//not-modified')).catch((e) => {
650
989
  if (e?.code === 'ENOENT')
651
990
  return true;
652
991
  if (debug)
@@ -680,20 +1019,20 @@ async function createDisplayTemplateHelper(typeFile, typeFileId, force = false,
680
1019
  typeContents.push(`export type ${displayTemplate.key}Props = LayoutProps<typeof ${displayTemplate.key}Styles>`);
681
1020
  typeContents.push(`export type ${displayTemplate.key}Keys = LayoutPropsSettingKeys<${displayTemplate.key}Props>`);
682
1021
  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>>`);
1022
+ 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']`);
1023
+ typeContents.push(`export type ${displayTemplate.key}Component<DT extends Record<string, unknown> = Record<string, unknown>> = ComponentType<${displayTemplate.key}ComponentProps<DT>>`);
685
1024
  typeContents.push('');
686
1025
  props.push(`${displayTemplate.key}Props`);
687
1026
  if (!typeId)
688
1027
  typeId = displayTemplate.nodeType ?? displayTemplate.baseType ?? displayTemplate.contentType;
689
1028
  });
690
1029
  if (typeId) {
691
- typeId = ucFirst$1(typeId);
1030
+ typeId = ucFirst(typeId);
692
1031
  typeContents.push(`export type ${typeId}LayoutProps = ${props.join(' | ')}
693
1032
  export type ${typeId}LayoutKeys = LayoutPropsSettingKeys<${typeId}LayoutProps>
694
1033
  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>>`);
1034
+ export type ${typeId}ComponentProps<DT extends Record<string, unknown> = Record<string, unknown>> = Omit<CmsComponentProps<DT, ${typeId}LayoutProps>,'children'> & JSX.IntrinsicElements['div']
1035
+ export type ${typeId}Component<DT extends Record<string, unknown> = Record<string, unknown>> = ComponentType<${typeId}ComponentProps<DT>>`);
697
1036
  const defaultTemplate = templates.find(t => t.data.isDefault);
698
1037
  if (defaultTemplate) {
699
1038
  typeContents.push(`
@@ -710,7 +1049,7 @@ export function is${t.data.key}Props(props?: ${typeId}LayoutProps | null) : prop
710
1049
  }`);
711
1050
  });
712
1051
  }
713
- void await fsAsync.writeFile(typeFilePath, prefix + "\n" + imports.join("\n") + "\n\n" + typeContents.join("\n"));
1052
+ void await fs$1.writeFile(typeFilePath, prefix + "\n" + imports.join("\n") + "\n\n" + typeContents.join("\n"));
714
1053
  return true;
715
1054
  }
716
1055
 
@@ -724,10 +1063,6 @@ const StylesCreateCommand = {
724
1063
  handler: async (args) => {
725
1064
  const { components: basePath } = parseArgs(args);
726
1065
  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
1066
  const allowedBaseTypes = ['section', 'component', 'experience'];
732
1067
  // Prepare
733
1068
  process.stdout.write(chalk.yellowBright(chalk.bold(`Reading current information from Optimizely CMS\n`)));
@@ -776,10 +1111,10 @@ const StylesCreateCommand = {
776
1111
  definition[type] = typeId;
777
1112
  definition['settings'] = {};
778
1113
  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))) {
1114
+ const { styleFile: styleFilePath, styleFolder: styleFileFolder } = await getStyleFilePaths(definition, { contentBaseType, client, basePath });
1115
+ if (!fs.existsSync(styleFileFolder))
1116
+ fs.mkdirSync(styleFileFolder, { recursive: true });
1117
+ if (fs.existsSync(styleFilePath)) {
783
1118
  const overwrite = await confirm({ message: "The style definition file already exists, do you want to overwrite it?" });
784
1119
  if (!overwrite) {
785
1120
  process.stdout.write("\n");
@@ -788,8 +1123,8 @@ const StylesCreateCommand = {
788
1123
  }
789
1124
  }
790
1125
  // @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`)));
1126
+ fs.writeFileSync(styleFilePath, JSON.stringify(definition, undefined, 4));
1127
+ process.stdout.write(chalk.yellowBright(chalk.bold(`\nWritten style defintion template to: ${path.normalize(styleFilePath)}\n`)));
793
1128
  if (await confirm({ message: "Do you want to publish this style into Optimizely CMS?" })) {
794
1129
  const response = await client.displayTemplatesCreate({ body: definition }).catch(_ => undefined);
795
1130
  if (!response) {
@@ -811,6 +1146,91 @@ const StylesCreateCommand = {
811
1146
  }
812
1147
  };
813
1148
 
1149
+ const StylesDeleteCommand = {
1150
+ command: "styles:delete",
1151
+ describe: "Remove Visual Builder style definitions from the CMS",
1152
+ builder: (yargs) => {
1153
+ const newYargs = stylesBuilder(yargs);
1154
+ newYargs.option('force', { alias: 'f', description: "Overwrite existing files", boolean: true, type: 'boolean', demandOption: false, default: false });
1155
+ newYargs.option('withStyleFile', { alias: 'w', description: "Delete the .opti-style.json file as weill", boolean: true, type: 'boolean', demandOption: false, default: true });
1156
+ newYargs.option("definitions", { alias: 'u', description: "Update typescript definitions", boolean: true, type: 'boolean', demandOption: false, default: true });
1157
+ return newYargs;
1158
+ },
1159
+ handler: async (args) => {
1160
+ const { components: basePath } = parseArgs(args);
1161
+ const client = createCmsClient(args);
1162
+ const { styles, all: allStyles } = await getStyles(client, args, 100);
1163
+ if (styles.findIndex(x => x.isDefault) >= 0)
1164
+ 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`))));
1165
+ if (!args.force) {
1166
+ process.stdout.write(`This will remove the following display templates\n`);
1167
+ for (const style of styles) {
1168
+ process.stdout.write(` ${figures.arrowRight} ${style.displayName || style.key} [Key: ${style.key}; Default: ${style.isDefault ? 'Yes' : 'No'}]\n`);
1169
+ }
1170
+ process.stdout.write(`\nRun with -f to actually preform removal\n`);
1171
+ return;
1172
+ }
1173
+ const keysToDelete = styles.map(displayTemplate => displayTemplate.key);
1174
+ const styleHelpers = await toTypeFilesList(allStyles, client, basePath);
1175
+ for (const displayTemplate of styles) {
1176
+ const { styleFile: styleFilePath, styleFolder: itemPath, identifier: targetType, helperFolder: typesPath } = styleHelpers.getDisplayTemplatePathsByKey(displayTemplate.key);
1177
+ // Remove style file
1178
+ if (args.withStyleFile) {
1179
+ process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Removing *.opti-style.json file for ${displayTemplate.displayName} [${displayTemplate.key}]\n`));
1180
+ await fs$1.rm(styleFilePath, { force: false, recursive: false, maxRetries: 1 }).catch((e) => {
1181
+ if (e?.code === 'ENOENT') // The file can't be deleted as it does not exist - that's the desired state
1182
+ return;
1183
+ throw e;
1184
+ });
1185
+ await fs$1.rmdir(itemPath, { recursive: false, maxRetries: 1 }).catch((e) => {
1186
+ if (e?.code === 'ENOTEMPTY') {
1187
+ process.stdout.write(chalk.yellowBright(`${figures.warning} The folder is not empty, please remove ${itemPath} manually if needed\n`));
1188
+ return;
1189
+ }
1190
+ else if (e?.code === 'ENOENT')
1191
+ return;
1192
+ else
1193
+ throw e;
1194
+ });
1195
+ }
1196
+ // Remove/update typescript helper
1197
+ if (args.definitions && styleHelpers.has(targetType)) {
1198
+ process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Removing/updating displayTemplates.ts file for ${displayTemplate.displayName} [${displayTemplate.key}]\n`));
1199
+ const remainingTemplates = styleHelpers[targetType].templates.filter(x => !keysToDelete.includes(x.key));
1200
+ if (remainingTemplates.length === 0) {
1201
+ await fs$1.rm(styleHelpers[targetType].filePath, { force: false, recursive: false, maxRetries: 1 }).catch((e) => {
1202
+ if (e?.code === 'ENOENT') // The file can't be deleted as it does not exist - that's the desired state
1203
+ return;
1204
+ throw e;
1205
+ });
1206
+ await fs$1.rmdir(typesPath, { recursive: false, maxRetries: 1 }).catch((e) => {
1207
+ if (e?.code === 'ENOTEMPTY') {
1208
+ process.stdout.write(chalk.yellowBright(`${figures.warning} The folder is not empty, please remove ${typesPath} manually if needed\n`));
1209
+ return;
1210
+ }
1211
+ else if (e?.code === 'ENOENT')
1212
+ return;
1213
+ else
1214
+ throw e;
1215
+ });
1216
+ }
1217
+ else {
1218
+ const newEntry = {
1219
+ filePath: styleHelpers[targetType].filePath,
1220
+ templates: remainingTemplates
1221
+ };
1222
+ if (!await createDisplayTemplateHelper(newEntry, targetType, false, false))
1223
+ process.stdout.write(chalk.yellowBright(`${figures.warning} The display template was not updated, please update ${newEntry.filePath} manually`));
1224
+ }
1225
+ }
1226
+ // Actually remove from CMS
1227
+ process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Removing the display template ${displayTemplate.displayName} [${displayTemplate.key}] from Optimizely CMS\n`));
1228
+ void await client.displayTemplatesDelete({ path: { key: displayTemplate.key } });
1229
+ }
1230
+ process.stdout.write("\n" + chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
1231
+ }
1232
+ };
1233
+
814
1234
  const TypesPullCommand = {
815
1235
  command: "types:pull",
816
1236
  describe: "Pull content type definition files into the project",
@@ -820,13 +1240,13 @@ const TypesPullCommand = {
820
1240
  return newArgs;
821
1241
  },
822
1242
  handler: async (args) => {
823
- const { _config: { debug }, components: basePath, force } = parseArgs(args);
1243
+ const { _config: { debug }, components: basePath, path: projectPath, force } = parseArgs(args);
824
1244
  const client = createCmsClient(args);
825
1245
  const { contentTypes } = await getContentTypes(client, args);
826
1246
  const updatedTypes = contentTypes.map(contentType => {
827
- const { typePath, typeFile } = getContentTypePaths(contentType, basePath);
828
- if (!fs.existsSync(typePath))
829
- fs.mkdirSync(typePath, { recursive: true });
1247
+ const { path: path$1, typeFile } = getContentTypePaths(contentType, basePath);
1248
+ if (!fs.existsSync(path$1))
1249
+ fs.mkdirSync(path$1, { recursive: true });
830
1250
  if (fs.existsSync(typeFile) && !force) {
831
1251
  if (debug)
832
1252
  process.stdout.write(chalk.yellow(`${figures.cross} Skipping type definition for ${contentType.displayName} (${contentType.key}) - File already exists\n`));
@@ -835,9 +1255,7 @@ const TypesPullCommand = {
835
1255
  const outContentType = { ...contentType };
836
1256
  if (outContentType.source || outContentType.source == "")
837
1257
  delete outContentType.source;
838
- //if (outContentType.features) delete outContentType.features
839
- //if (outContentType.usage) delete outContentType.usage
840
- if (outContentType.lastModifiedBy)
1258
+ if (outContentType.lastModifiedBy || outContentType.lastModifiedBy == "")
841
1259
  delete outContentType.lastModifiedBy;
842
1260
  if (outContentType.lastModified)
843
1261
  delete outContentType.lastModified;
@@ -858,16 +1276,13 @@ const TypesPullCommand = {
858
1276
  }
859
1277
  }
860
1278
  if (debug)
861
- process.stdout.write(chalk.gray(`${figures.arrowRight} Writing type definition for ${contentType.displayName} (${contentType.key})\n`));
1279
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Writing type definition for ${contentType.displayName} (${contentType.key}) into ${path.relative(projectPath, path$1)}\n`));
862
1280
  fs.writeFileSync(typeFile, JSON.stringify(outContentType, undefined, 2));
863
1281
  return contentType.key;
864
1282
  }).filter(x => x);
865
1283
  process.stdout.write(chalk.green(chalk.bold(`${figures.tick} Created/updated type definitions for ${updatedTypes.join(', ')}\n`)));
866
1284
  }
867
1285
  };
868
- function isEmptyArray(toTest) {
869
- return toTest === null || !Array.isArray(toTest) || toTest.length === 0;
870
- }
871
1286
 
872
1287
  const deepmerge$1 = createDeepMerge();
873
1288
  async function loadSchema(client, schemaName) {
@@ -1169,14 +1584,6 @@ function getTypeFolder(list, type) {
1169
1584
  return list.filter(x => x.type == type).at(0);
1170
1585
  }
1171
1586
 
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
1587
  const NextJsComponentsCommand = {
1181
1588
  command: "nextjs:components",
1182
1589
  describe: "Create the React Components for a Next.JS / Optimizely Graph structure",
@@ -1286,8 +1693,9 @@ ${varName}.getDataFragment = () => ['${contentType.key}Data', ${contentType.key}
1286
1693
 
1287
1694
  export default ${varName}`,
1288
1695
  // 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 ? `
1696
+ section: (contentType, varName, displayTemplate, baseDisplayTemplate) => `import { OptimizelyComposition, isNode, CmsEditable, type CmsComponent } from "@remkoj/optimizely-cms-react/rsc";
1697
+ import { get${contentType.key}DataDocument, type get${contentType.key}DataQuery, SectionCompositionDataFragmentDoc } from "@/gql/graphql";
1698
+ import { getFragmentData } from "@/gql/fragment-masking";${displayTemplate ? `
1291
1699
  import { ${displayTemplate} } from "./displayTemplates";` : ''}${baseDisplayTemplate ? `
1292
1700
  import { ${baseDisplayTemplate} } from "../styles/displayTemplates";` : ''}
1293
1701
 
@@ -1296,19 +1704,25 @@ import { ${baseDisplayTemplate} } from "../styles/displayTemplates";` : ''}
1296
1704
  * ---
1297
1705
  * ${contentType.description}
1298
1706
  */
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>
1707
+ export const ${varName} : CmsComponent<get${contentType.key}DataQuery${(displayTemplate || baseDisplayTemplate) ? ', ' + [displayTemplate, baseDisplayTemplate].filter(x => x).join(" | ") : ''}> = ({ data${(displayTemplate || baseDisplayTemplate) ? ', layoutProps' : ''}, editProps, children, ctx }) => {
1708
+ // If we're rendering stand-alone we'll get a composition back, with ourself included If we're
1709
+ // rendering as part of an experience, we'll get the data directly. So handle both cases.
1710
+ if (!data?._metadata && children) return children;
1711
+
1712
+ const componentName = '${contentType.displayName}'
1713
+ const componentInfo = '${contentType.description?.replaceAll("'", "\\'") ?? ''}'
1714
+ const composition = getFragmentData(SectionCompositionDataFragmentDoc, data)?.composition;
1715
+ return <CmsEditable className="w-full border-y border-y-solid border-y-slate-900 py-2 mb-4" {...editProps}>
1716
+ <div className="font-bold italic">{ componentName }</div>
1717
+ <div>{ componentInfo }</div>
1718
+ { 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> }
1719
+ ${(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 */}'}
1720
+ {children && <div>{ children }</div>}
1721
+ {composition && isNode(composition) && <OptimizelyComposition node={composition} ctx={ctx} />}
1722
+ </CmsEditable>
1309
1723
  }
1310
1724
  ${varName}.displayName = "${contentType.displayName} (${ucFirst(contentType.baseType)}/${contentType.key})"
1311
- ${varName}.getDataFragment = () => ['${contentType.key}Data', ${contentType.key}DataFragmentDoc]
1725
+ ${varName}.getDataQuery = () => get${contentType.key}DataDocument
1312
1726
 
1313
1727
  export default ${varName}`,
1314
1728
  // Template for all page component types
@@ -1347,10 +1761,11 @@ ${varName}.getMetaData = async (contentLink, locale, client) => {
1347
1761
 
1348
1762
  export default ${varName}`,
1349
1763
  // Template for all experience component types
1350
- experience: (contentType, varName, displayTemplate) => `import { type OptimizelyNextPage as CmsComponent } from "@remkoj/optimizely-cms-nextjs";
1764
+ experience: (contentType, varName, displayTemplate) => `import 'server-only'
1765
+ import { type OptimizelyNextPage as CmsComponent } from "@remkoj/optimizely-cms-nextjs";
1351
1766
  import { ExperienceDataFragmentDoc, get${contentType.key}DataDocument, type get${contentType.key}DataQuery } from '@/gql/graphql'
1352
1767
  import { getFragmentData } from '@/gql/fragment-masking'
1353
- import { OptimizelyComposition, isNode, CmsEditable } from "@remkoj/optimizely-cms-react/rsc";${displayTemplate ? `
1768
+ import { OptimizelyComposition, isNode, CmsEditable, type ServerContext } from "@remkoj/optimizely-cms-react/rsc";${displayTemplate ? `
1354
1769
  import { ${displayTemplate} } from "./displayTemplates";` : ''}
1355
1770
  import { getSdk } from "@/gql/client"
1356
1771
 
@@ -1366,7 +1781,7 @@ import { getSdk } from "@/gql/client"
1366
1781
  * [Documentation: Customizing queries](https://github.com/remkoj/optimizely-dxp-clients/blob/main/packages/optimizely-graph-functions/docs/customizing_queries.md)
1367
1782
  */
1368
1783
  export const ${varName} : CmsComponent<get${contentType.key}DataQuery${displayTemplate ? ', ' + displayTemplate : ''}> = ({ data${displayTemplate ? ', layoutProps' : ''}, ctx }) => {
1369
- if (ctx) ctx.editableContentIsExperience = true
1784
+ if (ctx) (ctx as ServerContext).setEditableContentIsExperience(true)
1370
1785
  const composition = getFragmentData(ExperienceDataFragmentDoc, data).composition
1371
1786
  const componentName = '${contentType.displayName}'
1372
1787
  const componentInfo = '${contentType.description?.replaceAll("'", "\\'") ?? ''}'
@@ -1401,13 +1816,13 @@ const NextJsVisualBuilderCommand = {
1401
1816
  const { styles } = await getStyles(createCmsClient(args), { ...args, excludeNodeTypes: [], excludeTemplates: [], nodes: [], templates: [], templateTypes: ['node', 'base'] });
1402
1817
  // Process node styles
1403
1818
  styles.filter(x => typeof (x.nodeType) == 'string' && x.nodeType.length > 0).map(styleDefintion => {
1404
- const templatePath = path.join(basePath, 'nodes', styleDefintion.nodeType, styleDefintion.key);
1819
+ const templatePath = path.join(basePath, 'nodes', keyToSlug(styleDefintion.nodeType), keyToSlug(styleDefintion.key));
1405
1820
  createSpecificNode(styleDefintion, templatePath, force, debug);
1406
1821
  });
1407
1822
  // Process base styles
1408
1823
  styles.filter(x => typeof (x.baseType) == 'string' && x.baseType.length > 0).map(styleDefinition => {
1409
1824
  const baseType = (styleDefinition.baseType ?? '').startsWith('_') ? (styleDefinition.baseType ?? '').substring(1) : styleDefinition.baseType;
1410
- const templatePath = path.join(basePath, baseType, 'styles', styleDefinition.key);
1825
+ const templatePath = path.join(basePath, baseType, 'styles', keyToSlug(styleDefinition.key));
1411
1826
  createSpecificNode(styleDefinition, templatePath, force, debug);
1412
1827
  });
1413
1828
  }
@@ -1417,14 +1832,14 @@ function createSpecificNode(template, templatePath, force = false, debug = false
1417
1832
  if (fs.existsSync(nodeFile)) {
1418
1833
  if (!force) {
1419
1834
  if (debug)
1420
- process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${template.displayName} (${template.key}) component - file already exists\n`));
1835
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Skipping ${template.displayName} (${template.key}) node - file already exists\n`));
1421
1836
  return undefined;
1422
1837
  }
1423
1838
  if (debug)
1424
- process.stdout.write(chalk.gray(`${figures.arrowRight} Overwriting ${template.displayName} (${template.key}) component\n`));
1839
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Overwriting ${template.displayName} (${template.key}) node\n`));
1425
1840
  }
1426
1841
  else if (debug)
1427
- process.stdout.write(chalk.gray(`${figures.arrowRight} Creating ${template.displayName} (${template.key}) component\n`));
1842
+ process.stdout.write(chalk.gray(`${figures.arrowRight} Creating ${template.displayName} (${template.key}) node\n`));
1428
1843
  if (!fs.existsSync(templatePath))
1429
1844
  fs.mkdirSync(templatePath, { recursive: true });
1430
1845
  const baseType = template.nodeType ?? template.baseType ?? 'unknown';
@@ -1502,6 +1917,7 @@ function getDisplayTemplateInfo(template, typePath) {
1502
1917
 
1503
1918
  const ROOT_FACTORY_KEY = ".";
1504
1919
  const FACTORY_FILE_NAME = "index.ts";
1920
+ const reservedNames = ["loading.tsx", "loading.jsx", "suspense.tsx", "suspense.jsx"];
1505
1921
  const NextJsFactoryCommand = {
1506
1922
  command: "nextjs:factory",
1507
1923
  describe: "Create the ComponentFactory for a Next.JS / Optimizely Graph structure",
@@ -1510,108 +1926,102 @@ const NextJsFactoryCommand = {
1510
1926
  const { components: basePath, force, _config: { debug } } = parseArgs(args);
1511
1927
  if (debug)
1512
1928
  process.stdout.write(chalk.gray(`${figures.arrowRight} Start generating component factories\n`));
1929
+ // Get & filter all component files
1930
+ const clientComponents = [];
1513
1931
  const components = globSync(["./**/*.jsx", "./**/*.tsx"], {
1514
1932
  cwd: basePath
1515
1933
  }).map(p => p.split(path.sep)).filter(p => {
1934
+ // Get the filename
1935
+ const fileName = p.at(p.length - 1);
1936
+ if (!fileName)
1937
+ return false;
1516
1938
  // Consider components in a file starting with "_" as a partial
1517
- if (p.at(p.length - 1)?.startsWith('_') == true)
1939
+ if (fileName.startsWith('_') == true)
1518
1940
  return false;
1519
1941
  // Consider components in a folder named "partials" as a partial
1520
- if (p.at(p.length - 2)?.toLowerCase() == 'partials')
1942
+ if (p.some(folder => folder === 'partials'))
1521
1943
  return false;
1522
1944
  // Skip the special loader component
1523
- if (p.at(p.length - 1) == "loading.tsx" || p.at(p.length - 1) == "loading.jsx")
1945
+ if (reservedNames.includes(fileName))
1524
1946
  return false;
1525
1947
  // Check if the file has a default export
1526
1948
  const fileBuffer = fs.readFileSync(path.join(basePath, p.join(path.sep)));
1527
1949
  const hasDefaultExport = fileBuffer.includes('export default');
1950
+ if (fileBuffer.includes('use client'))
1951
+ clientComponents.push(p.join(path.sep));
1528
1952
  if (!hasDefaultExport) {
1529
1953
  process.stdout.write(chalk.redBright(`${figures.warning} No default export in ${p.join(path.sep)} - ignoring file\n`));
1530
1954
  return false;
1531
1955
  }
1532
1956
  return true;
1533
1957
  });
1958
+ // Report what we have found
1534
1959
  if (debug)
1535
1960
  process.stdout.write(chalk.gray(`${figures.arrowRight} Identified ${components.length} components in ${basePath}\n`));
1961
+ // Build factory / component structure
1536
1962
  const componentFactoryDefintions = new Map();
1537
1963
  components.forEach(component => {
1964
+ const componentDir = path.dirname(path.posix.join(...component));
1965
+ // const componentFile = path.basename(path.posix.join(...component));
1966
+ // Determine component target
1967
+ const componentKey = getComponentKey(component, basePath);
1968
+ let componentVariant = (component.length > 1 ? component.at(component.length - 1) ?? 'default' : 'default').replace('index', 'default');
1969
+ componentVariant = path.basename(componentVariant, path.extname(componentVariant));
1970
+ // Get factory information
1538
1971
  const factorySegments = component.length > 2 ? component.slice(0, -2) : [ROOT_FACTORY_KEY];
1539
- const factoryKey = factorySegments.join(path.sep);
1972
+ const factoryKey = path.posix.join(...factorySegments);
1540
1973
  const factoryFile = path.join(factoryKey, FACTORY_FILE_NAME);
1541
- // Add component to factory
1974
+ // Check dynamic & suspense
1975
+ const isClientComponent = clientComponents.includes(component.join(path.sep));
1976
+ const useDynamic = [
1977
+ path.join(basePath, componentDir, 'loading.tsx'),
1978
+ path.join(basePath, componentDir, 'loading.jsx')
1979
+ ].some(fs.existsSync);
1980
+ const useSuspense = [
1981
+ path.join(basePath, componentDir, 'suspense.tsx'),
1982
+ path.join(basePath, componentDir, 'suspense.jsx')
1983
+ ].some(fs.existsSync);
1984
+ // Prepare data
1985
+ const componentImport = component.length == 1 ?
1986
+ `.${path.posix.sep}${path.basename(component[0], path.extname(component[0]))}` :
1987
+ `.${path.posix.sep}${path.posix.relative(factoryKey, componentDir)}${componentVariant !== 'default' ? path.posix.sep + componentVariant : ''}`;
1988
+ const loaderImport = useDynamic ? componentImport + path.posix.sep + 'loading' : undefined;
1989
+ const suspenseImport = useSuspense ? componentImport + path.posix.sep + 'suspense' : undefined;
1990
+ const componentVariablesBase = componentKey + (componentVariant !== 'default' ? processName(componentVariant) : '');
1991
+ // Add to the factory
1542
1992
  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
1993
  factory.entries.push({
1994
+ key: componentKey,
1995
+ variant: componentVariant,
1558
1996
  import: componentImport,
1559
- variable: [...componentSegments.map(processName), 'Component'].join(''),
1560
- key: componentSegments.join('/') == "node" ? "Node" : componentSegments.join('/'),
1997
+ variable: componentVariablesBase + 'Component',
1561
1998
  loaderImport,
1562
- loaderVariable: useSuspense ? [...componentSegments.map(processName), 'Loader'].join('') : undefined,
1999
+ loaderVariable: useDynamic ? componentVariablesBase + 'Loader' : undefined,
2000
+ suspenseImport,
2001
+ suspenseVariable: useSuspense ? componentVariablesBase + 'Placeholder' : undefined,
2002
+ isClient: isClientComponent
1563
2003
  });
1564
2004
  componentFactoryDefintions.set(factoryKey, factory);
1565
- // Register with all appropriate parents
2005
+ // Add/update parent factories
1566
2006
  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;
2007
+ parentSegements.forEach((_, idx, data) => {
2008
+ const parentFactoryKey = path.posix.join(...data.slice(0, idx + 1)) || ROOT_FACTORY_KEY;
2009
+ const childFactoryKey = factorySegments.slice(idx, idx + 1).at(0);
1575
2010
  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
- };
2011
+ if (!parentFactory.subfactories.some(x => x.key === childFactoryKey)) {
2012
+ parentFactory.subfactories.push({
2013
+ key: childFactoryKey,
2014
+ import: './' + childFactoryKey,
2015
+ variable: processName(childFactoryKey) + 'Factory'
2016
+ });
2017
+ componentFactoryDefintions.set(parentFactoryKey, parentFactory);
1586
2018
  }
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
2019
  });
1612
- }
2020
+ });
2021
+ // Report factory file count
1613
2022
  if (debug)
1614
2023
  process.stdout.write(chalk.gray(`${figures.arrowRight} Finished preparing ${componentFactoryDefintions.size} factories, start writing\n`));
2024
+ // Iterate over the factories and create them
1615
2025
  let updateCounter = 0;
1616
2026
  for (const key of componentFactoryDefintions.keys()) {
1617
2027
  const factory = componentFactoryDefintions.get(key);
@@ -1635,6 +2045,36 @@ const NextJsFactoryCommand = {
1635
2045
  process.stdout.write("\n");
1636
2046
  }
1637
2047
  };
2048
+ /**
2049
+ * Get the component key that must be used for the variable in the factory
2050
+ * this looks first at the component-type file.
2051
+ *
2052
+ * @param component The file path broken into parts
2053
+ * @returns The key to use
2054
+ */
2055
+ function getComponentKey(component, componentsRootDir) {
2056
+ // Prepare context
2057
+ //const componentFile = path.basename(path.join(...component));
2058
+ const componentDir = path.dirname(path.join(...component));
2059
+ // Calculate the fallback value
2060
+ const baseName = processName(component.length == 1 ? ucFirst(path.basename(component[0], path.extname(component[0]))) : component.at(component.length - 2));
2061
+ // Try to read the key from the opti-type.json file in the same folder
2062
+ const definitions = globSync("*.opti-type.json", { cwd: path.join(componentsRootDir, componentDir) });
2063
+ //console.log(definitions, componentDir);
2064
+ if (definitions.length == 1) {
2065
+ try {
2066
+ const data = JSON.parse(fs.readFileSync(path.join(componentsRootDir, componentDir, definitions[0])).toString('utf8'));
2067
+ //console.log('Updating to', data.key)
2068
+ if (data.key)
2069
+ return data.key;
2070
+ }
2071
+ catch {
2072
+ // Ignored on purpose
2073
+ }
2074
+ }
2075
+ // Return the fallback value
2076
+ return baseName;
2077
+ }
1638
2078
  function shouldWriteFactory(factoryFile, force = false, debug = false) {
1639
2079
  if (!fs.existsSync(factoryFile)) {
1640
2080
  process.stdout.write(chalk.green(`${figures.tick} Creating new factory file: ${factoryFile}\n`));
@@ -1656,63 +2096,68 @@ function shouldWriteFactory(factoryFile, force = false, debug = false) {
1656
2096
  function processName(input) {
1657
2097
  if (input == ROOT_FACTORY_KEY)
1658
2098
  return "Cms";
1659
- const nameSegements = input.split(/[-\_]/g);
2099
+ const nameSegements = input.split(/[-_]/g);
1660
2100
  return nameSegements.map(ucFirst).join('');
1661
2101
  }
1662
2102
  function generateFactory(factoryInfo, factoryKey) {
1663
- const needsPrefixFunction = factoryInfo.subfactories.length > 0;
1664
- 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
2103
+ // Get the factory name
2104
+ const factoryName = factoryKey.split(path.posix.sep).map(processName).join("") + "Factory";
2105
+ // Get the components and sub-factories, sorted by key to minimize changes between runs
2106
+ const components = [...factoryInfo.entries].sort((a, b) => { return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; });
2107
+ const subFactories = [...factoryInfo.subfactories].sort((a, b) => { return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; });
2108
+ // Check if there's at least one component that uses next/dynamic
2109
+ const hasDynamic = factoryInfo.entries.some(x => x.loaderImport);
2110
+ const hasClientComponents = factoryInfo.entries.some(x => x.isClient);
2111
+ // The intro for the factory
2112
+ const factoryIntro = `// Auto generated dictionary
1668
2113
  // @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;
2114
+ import { type ComponentTypeDictionary } from '@remkoj/optimizely-cms-react';${hasDynamic || hasClientComponents ? `
2115
+ import dynamic from 'next/dynamic';` : ''}`;
2116
+ // The outry for the factory
2117
+ const factoryOutro = `// Export dictionary
2118
+ export default ${factoryName};`;
2119
+ // The imports of the factory
2120
+ const factoryImports = [...components, ...subFactories].map(x => {
2121
+ const imports = [];
2122
+ if (x.loaderImport)
2123
+ imports.push(`import ${x.loaderVariable} from '${x.loaderImport}';`);
2124
+ else if (!x.isClient)
2125
+ imports.push(`import ${x.variable} from '${x.import}';`);
2126
+ if (x.suspenseImport)
2127
+ imports.push(`import ${x.suspenseVariable} from '${x.suspenseImport}';`);
2128
+ return imports.length > 0 ? imports.join('\n') : undefined;
2129
+ }).filter(x => x).join('\n');
2130
+ // The dynamic imports of the factory
2131
+ const dynamicImports = hasDynamic ? `// Lazy load components that have a loading file, this only affects client components
2132
+ // See https://nextjs.org/docs/app/guides/lazy-loading#importing-server-components
2133
+ // for more details on how this affects server components in Next.js
2134
+ ` + components.filter(x => x.loaderImport).map(x => {
2135
+ return `const ${x.variable} = dynamic(() => import('${x.import}'), {
2136
+ ssr: true,
2137
+ loading: ${x.loaderVariable}
2138
+ });`;
2139
+ }).join('\n') : undefined;
2140
+ // The client imports of the factory
2141
+ const clientComponents = hasClientComponents ? `// Lazy load client components even without loader specified
2142
+ ` + components.filter(x => x.isClient && !x.loaderImport).map(x => {
2143
+ return `const ${x.variable} = dynamic(() => import('${x.import}'), { ssr: true });`;
2144
+ }).join('\n') : undefined;
2145
+ // The actual entries for the factory
2146
+ const factoryEntries = [...components.map(x => {
2147
+ return ` {
2148
+ type: '${x.key}',${x.variant && x.variant !== 'default' ? `
2149
+ variant: '${x.variant}',` : ''}
2150
+ component: ${x.variable}${x.suspenseVariable ? `,
2151
+ useSuspense: true,
2152
+ loader: ${x.suspenseVariable}` : ''}${x.isClient ? `,
2153
+ isClient: true` : ''}
2154
+ }`;
2155
+ }), ...subFactories.map(x => ` ...${x.variable}`)];
2156
+ // The body of the factory
2157
+ const factoryBody = `// Build dictionary
2158
+ export const ${factoryName} : ComponentTypeDictionary = [${factoryEntries.length > 0 ? '\n' + factoryEntries.join(',\n') + '\n' : ''}];`;
2159
+ // Combine everything into one string
2160
+ return [factoryIntro, factoryImports, dynamicImports, clientComponents, factoryBody, factoryOutro].filter(x => (x?.length || 0) > 0).join('\n\n') + '\n';
1716
2161
  }
1717
2162
 
1718
2163
  const NextJsCreateCommand = {
@@ -1752,16 +2197,23 @@ const NextJsFragmentsCommand = {
1752
2197
  handler: async (args, opts) => {
1753
2198
  // Prepare
1754
2199
  const { loadedContentTypes, createdTypeFolders } = opts || {};
1755
- const { components: basePath, _config: { debug }, force } = parseArgs(args);
2200
+ const { components: basePath, _config: { debug }, force, path: appPath } = parseArgs(args);
1756
2201
  const client = createCmsClient(args);
2202
+ // Get content types
1757
2203
  const { contentTypes, all: allContentTypes } = loadedContentTypes ?? await getContentTypes(client, args);
2204
+ const allContentTypesMap = new Map();
2205
+ allContentTypes.forEach(x => {
2206
+ if (x.key)
2207
+ allContentTypesMap.set(x.key, x);
2208
+ });
2209
+ const generator = new DocumentGenerator(allContentTypesMap);
1758
2210
  // Start process
1759
2211
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Generating GraphQL fragments\n`));
1760
2212
  const typeFolders = createdTypeFolders ?? createTypeFolders(contentTypes, basePath, debug);
1761
- const tracker = new Map();
2213
+ const tracker = new PropertyCollisionTracker(appPath);
1762
2214
  const dependencies = [];
1763
2215
  const updatedTypes = contentTypes.map(contentType => {
1764
- const { written, propertyTypes } = createComponentFragments(contentType, getTypeFolder(typeFolders, contentType.key), force, debug, tracker);
2216
+ const { written, propertyTypes } = createComponentFragments(contentType, getTypeFolder(typeFolders, contentType.key), force, debug, tracker, generator);
1765
2217
  dependencies.push(...propertyTypes);
1766
2218
  return written ? contentType.key : undefined;
1767
2219
  }).filter(x => x);
@@ -1781,7 +2233,7 @@ const NextJsFragmentsCommand = {
1781
2233
  typeFolders.push(tf);
1782
2234
  }
1783
2235
  return tf;
1784
- }, force, debug);
2236
+ }, force, debug, tracker, generator);
1785
2237
  // Report outcome
1786
2238
  if (generatedProps.length > 0)
1787
2239
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Created/updated GraphQL property fragments for ${generatedProps.join(', ')}\n`));
@@ -1791,7 +2243,7 @@ const NextJsFragmentsCommand = {
1791
2243
  process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
1792
2244
  }
1793
2245
  };
1794
- function createComponentFragments(contentType, typePath, force, debug, propertyTracker = new Map()) {
2246
+ function createComponentFragments(contentType, typePath, force, debug, propertyTracker = new Map(), generator) {
1795
2247
  const baseQueryFile = typePath.fragmentFile;
1796
2248
  let writeFragment = true;
1797
2249
  if (fs.existsSync(baseQueryFile)) {
@@ -1810,13 +2262,13 @@ function createComponentFragments(contentType, typePath, force, debug, propertyT
1810
2262
  }
1811
2263
  let written = false;
1812
2264
  if (writeFragment) {
1813
- const fragment = GraphQLGen.buildFragment(contentType, undefined, false, propertyTracker);
2265
+ const fragment = generator.buildFragment(contentType, undefined, false, propertyTracker);
1814
2266
  fs.writeFileSync(baseQueryFile, fragment);
1815
2267
  written = true;
1816
2268
  }
1817
- return { written, propertyTypes: GraphQLGen.getReferencedPropertyComponents(contentType) };
2269
+ return { written, propertyTypes: DocumentGenerator.getReferencedPropertyComponents(contentType) };
1818
2270
  }
1819
- function createPropertyFragments(propertyTypesList, allContentTypes, selectTypeFolder, force = false, debug = false) {
2271
+ function createPropertyFragments(propertyTypesList, allContentTypes, selectTypeFolder, force = false, debug = false, propertyTracker = new Map(), generator) {
1820
2272
  const returnValue = [];
1821
2273
  for (const propertyContentTypeKey of propertyTypesList.filter((v, i, a) => a.indexOf(v, i + 1) <= i)) {
1822
2274
  // Load the ContentType definition
@@ -1848,16 +2300,16 @@ function createPropertyFragments(propertyTypesList, allContentTypes, selectTypeF
1848
2300
  process.stdout.write(chalk.gray(`${figures.arrowRight} Creating ${contentType.displayName} (${contentType.key}) property fragment\n`));
1849
2301
  }
1850
2302
  if (mustWrite) {
1851
- const fragment = GraphQLGen.buildFragment(contentType, undefined, true);
2303
+ const fragment = generator.buildFragment(contentType, undefined, true, propertyTracker);
1852
2304
  fs.writeFileSync(propertyFragmentFile, fragment);
1853
2305
  returnValue.push(contentType.key);
1854
2306
  }
1855
2307
  // Recurse down for properties that we're not yet rendering
1856
- const referencedPropertyTypes = GraphQLGen.getReferencedPropertyComponents(contentType).filter(x => !propertyTypesList.includes(x));
2308
+ const referencedPropertyTypes = DocumentGenerator.getReferencedPropertyComponents(contentType).filter(x => !propertyTypesList.includes(x));
1857
2309
  if (referencedPropertyTypes.length > 0) {
1858
2310
  if (debug)
1859
2311
  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);
2312
+ const additionalProperties = createPropertyFragments(referencedPropertyTypes, allContentTypes, selectTypeFolder, force, debug, propertyTracker, generator);
1861
2313
  returnValue.push(...additionalProperties);
1862
2314
  }
1863
2315
  }
@@ -1875,16 +2327,23 @@ const NextJsQueriesCommand = {
1875
2327
  handler: async (args, opts) => {
1876
2328
  // Prepare
1877
2329
  const { loadedContentTypes, createdTypeFolders } = opts || {};
1878
- const { components: basePath, _config: { debug }, force } = parseArgs(args);
2330
+ const { components: basePath, _config: { debug }, force, path: appPath } = parseArgs(args);
1879
2331
  const client = createCmsClient(args);
1880
2332
  const { contentTypes, all: allContentTypes } = loadedContentTypes ?? await getContentTypes(client, args);
2333
+ const allContentTypesMap = new Map();
2334
+ allContentTypes.forEach(x => {
2335
+ if (x.key)
2336
+ allContentTypesMap.set(x.key, x);
2337
+ });
2338
+ const generator = new DocumentGenerator(allContentTypesMap);
2339
+ const propertyTracker = new PropertyCollisionTracker(appPath);
1881
2340
  // Start process
1882
2341
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Generating GraphQL queries\n`));
1883
2342
  const dependencies = [];
1884
2343
  const typeFolders = createdTypeFolders ?? createTypeFolders(contentTypes, basePath, debug);
1885
2344
  const updatedTypes = contentTypes.map(contentType => {
1886
2345
  const typePath = getTypeFolder(typeFolders, contentType.key);
1887
- const { written, propertyTypes } = createGraphQueries(contentType, typePath, force, debug);
2346
+ const { written, propertyTypes } = createGraphQueries(contentType, typePath, force, debug, propertyTracker, generator);
1888
2347
  dependencies.push(...propertyTypes);
1889
2348
  return written ? contentType.key : undefined;
1890
2349
  }).filter(x => x).flat();
@@ -1903,7 +2362,7 @@ const NextJsQueriesCommand = {
1903
2362
  typeFolders.push(tf);
1904
2363
  }
1905
2364
  return tf;
1906
- }, force, debug);
2365
+ }, force, debug, propertyTracker, generator);
1907
2366
  // Report outcome
1908
2367
  if (generatedProps.length > 0)
1909
2368
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Created/updated GraphQL property fragments for ${generatedProps.join(', ')}\n`));
@@ -1913,7 +2372,7 @@ const NextJsQueriesCommand = {
1913
2372
  process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
1914
2373
  }
1915
2374
  };
1916
- function createGraphQueries(contentType, typePath, force, debug) {
2375
+ function createGraphQueries(contentType, typePath, force, debug, tracker, generator) {
1917
2376
  const baseQueryFile = typePath.queryFile;
1918
2377
  let mustWrite = true;
1919
2378
  if (fs.existsSync(baseQueryFile)) {
@@ -1931,12 +2390,12 @@ function createGraphQueries(contentType, typePath, force, debug) {
1931
2390
  process.stdout.write(chalk.gray(`${figures.arrowRight} Creating ${contentType.displayName} (${contentType.key}) base fragment\n`));
1932
2391
  }
1933
2392
  if (mustWrite) {
1934
- const fragment = GraphQLGen.buildGetQuery(contentType, undefined, new Map());
2393
+ const fragment = generator.buildGetQuery(contentType, undefined, tracker);
1935
2394
  fs.writeFileSync(baseQueryFile, fragment);
1936
2395
  }
1937
2396
  return {
1938
2397
  written: mustWrite,
1939
- propertyTypes: GraphQLGen.getReferencedPropertyComponents(contentType)
2398
+ propertyTypes: DocumentGenerator.getReferencedPropertyComponents(contentType)
1940
2399
  };
1941
2400
  }
1942
2401
 
@@ -1958,12 +2417,12 @@ const SchemaDownloadCommand = {
1958
2417
  const fullSchemaDir = path.normalize(path.join(projectPath, schemaDir));
1959
2418
  const relativeSchemaDir = path.relative(projectPath, fullSchemaDir);
1960
2419
  process.stdout.write(`\n${figures.arrowRight} Validating schema folder ${relativeSchemaDir}\n`);
1961
- const schemaDirInfo = await fsAsync.stat(fullSchemaDir).catch((e) => { if (e.code == 'ENOENT')
2420
+ const schemaDirInfo = await fs$1.stat(fullSchemaDir).catch((e) => { if (e.code == 'ENOENT')
1962
2421
  return undefined;
1963
2422
  else
1964
2423
  throw e; });
1965
2424
  if (!schemaDirInfo) {
1966
- await fsAsync.mkdir(fullSchemaDir, { recursive: true });
2425
+ await fs$1.mkdir(fullSchemaDir, { recursive: true });
1967
2426
  }
1968
2427
  else if (!schemaDirInfo.isDirectory()) {
1969
2428
  process.stderr.write(chalk.redBright(chalk.bold(`${figures.cross} [Error] The schema directory exists, but is not a directory (${relativeSchemaDir})\n`)));
@@ -1976,7 +2435,7 @@ const SchemaDownloadCommand = {
1976
2435
  const schemaFile = path.normalize(path.join(fullSchemaDir, `${schema.title.toLowerCase()}.schema.json`));
1977
2436
  const relativePath = path.relative(projectPath, schemaFile);
1978
2437
  try {
1979
- await fsAsync.writeFile(schemaFile, JSON.stringify(schema.schema, undefined, 2), {
2438
+ await fs$1.writeFile(schemaFile, JSON.stringify(schema.schema, undefined, 2), {
1980
2439
  encoding: "utf-8",
1981
2440
  flag: force ? 'w' : 'wx'
1982
2441
  });
@@ -2216,7 +2675,293 @@ function writeFileAsync(path, data) {
2216
2675
  });
2217
2676
  }
2218
2677
 
2219
- var version = "6.0.0-pre9";
2678
+ const MigrateCommand = {
2679
+ command: "project:migrate",
2680
+ describe: "Automate the directory naming convention update",
2681
+ builder,
2682
+ async handler(args, opts) {
2683
+ const { components: componentsPath} = parseArgs(args);
2684
+ const files = await glob(['*/**/*'], { cwd: componentsPath });
2685
+ const operations = [];
2686
+ for (const file of files) {
2687
+ const fullPath = path.join(componentsPath, file);
2688
+ const pathInfo = await fs$1.stat(fullPath);
2689
+ if (pathInfo.isDirectory()) { // We only need to rename directories
2690
+ const dirName = path.basename(fullPath);
2691
+ const dirParent = path.dirname(fullPath);
2692
+ const newDirName = keyToSlug(dirName);
2693
+ // We're ignoring casing as changing that will confuse Git
2694
+ if (dirName.toLowerCase() !== newDirName.toLowerCase()) {
2695
+ process.stdout.write(`${figures.arrowRight} Updating ${file}\n`);
2696
+ const newFullPath = path.join(dirParent, newDirName);
2697
+ operations.push({ from: fullPath, to: newFullPath });
2698
+ }
2699
+ }
2700
+ if (pathInfo.isFile() && (fullPath.endsWith('.opti-style.json') || fullPath.endsWith('.opti-type.json'))) {
2701
+ const fileName = path.basename(fullPath);
2702
+ const newFileName = keyToSlug(fileName, { preserveCharacters: ['.'] });
2703
+ if (fileName.toLowerCase() !== newFileName.toLowerCase()) {
2704
+ const filePath = path.dirname(fullPath);
2705
+ const newFullPath = path.join(filePath, newFileName);
2706
+ operations.unshift({ from: fullPath, to: newFullPath });
2707
+ }
2708
+ }
2709
+ }
2710
+ for (const operation of operations) {
2711
+ void await fs$1.rename(operation.from, operation.to);
2712
+ }
2713
+ NextJsFactoryCommand.handler(args);
2714
+ }
2715
+ };
2716
+
2717
+ const SECTION_START = '<!-- @remkoj/optimizely-packages:start -->';
2718
+ const SECTION_END = '<!-- @remkoj/optimizely-packages:end -->';
2719
+ const ProjectAiCommand = {
2720
+ command: 'project:ai',
2721
+ describe: 'Create or update AI assistant configuration files (AGENTS.md, CLAUDE.md, GitHub Copilot, Cursor) so installed @remkoj package documentation is available to the model',
2722
+ builder: (yargs) => {
2723
+ yargs.option('force', {
2724
+ alias: 'f',
2725
+ description: 'Overwrite existing files entirely instead of merging into existing content',
2726
+ boolean: true,
2727
+ type: 'boolean',
2728
+ demandOption: false,
2729
+ default: false,
2730
+ });
2731
+ yargs.option('tools', {
2732
+ alias: 't',
2733
+ description: 'AI tools to configure',
2734
+ array: true,
2735
+ type: 'string',
2736
+ choices: ['agents', 'claude', 'copilot', 'cursor'],
2737
+ demandOption: false,
2738
+ default: ['agents', 'claude', 'copilot', 'cursor'],
2739
+ });
2740
+ return yargs;
2741
+ },
2742
+ handler: async (args) => {
2743
+ const projectPath = path.isAbsolute(args.path)
2744
+ ? args.path
2745
+ : path.normalize(path.join(process.cwd(), args.path));
2746
+ const force = args.force ?? false;
2747
+ const tools = args.tools ?? ['agents', 'claude', 'copilot', 'cursor'];
2748
+ // Discover installed @remkoj packages that include AGENTS.md
2749
+ process.stdout.write(`\n${figures.arrowRight} Scanning for @remkoj packages with AGENTS.md\n`);
2750
+ const packages = await findRemkojPackages(projectPath);
2751
+ if (packages.length === 0) {
2752
+ process.stderr.write(chalk.yellowBright(`\n${figures.warning} No @remkoj packages with AGENTS.md found in node_modules — run \`npm install\` first.\n`));
2753
+ return;
2754
+ }
2755
+ for (const pkg of packages) {
2756
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} ${chalk.bold(pkg.name)}\n`);
2757
+ }
2758
+ if (tools.includes('agents')) {
2759
+ process.stdout.write(`\n${figures.arrowRight} Updating AGENTS.md\n`);
2760
+ await updateWithSectionMarkers(path.join(projectPath, 'AGENTS.md'), buildAgentsMdSection(packages, projectPath), buildAgentsMdPreamble(), force, projectPath);
2761
+ }
2762
+ if (tools.includes('claude')) {
2763
+ process.stdout.write(`\n${figures.arrowRight} Writing CLAUDE.md\n`);
2764
+ await writeNewFileOnly(path.join(projectPath, 'CLAUDE.md'), `# CLAUDE.md\n\nSee [AGENTS.md](./AGENTS.md) for guidance on working with the Optimizely DXP packages in this project.\n`, force, projectPath);
2765
+ }
2766
+ if (tools.includes('copilot')) {
2767
+ process.stdout.write(`\n${figures.arrowRight} Updating GitHub Copilot instruction files\n`);
2768
+ await updateCopilotInstructions(projectPath, packages);
2769
+ }
2770
+ if (tools.includes('cursor')) {
2771
+ process.stdout.write(`\n${figures.arrowRight} Writing Cursor rules\n`);
2772
+ const rulesDir = path.join(projectPath, '.cursor', 'rules');
2773
+ await ensureDir(rulesDir);
2774
+ await writeCursorRules(path.join(rulesDir, 'optimizely-packages.mdc'), packages, projectPath);
2775
+ }
2776
+ process.stdout.write(`\n${chalk.greenBright(chalk.bold(figures.tick))} AI assistant configuration updated successfully\n`);
2777
+ },
2778
+ };
2779
+ // ── Helpers ───────────────────────────────────────────────────────────────────
2780
+ async function findRemkojPackages(projectPath) {
2781
+ const scopeDir = path.join(projectPath, 'node_modules', '@remkoj');
2782
+ const packages = [];
2783
+ try {
2784
+ const dirs = await fs$1.readdir(scopeDir);
2785
+ for (const dir of dirs) {
2786
+ const agentsPath = path.join(scopeDir, dir, 'AGENTS.md');
2787
+ try {
2788
+ await fs$1.access(agentsPath);
2789
+ packages.push({ name: `@remkoj/${dir}`, agentsPath });
2790
+ }
2791
+ catch {
2792
+ // Package has no AGENTS.md — skip silently
2793
+ }
2794
+ }
2795
+ }
2796
+ catch {
2797
+ // @remkoj scope directory not found in node_modules
2798
+ }
2799
+ return packages;
2800
+ }
2801
+ function wrapSection(content) {
2802
+ return `${SECTION_START}\n${content}\n${SECTION_END}`;
2803
+ }
2804
+ function buildReferenceList(packages, projectPath) {
2805
+ return packages
2806
+ .map((pkg) => {
2807
+ const rel = path.relative(projectPath, pkg.agentsPath).replace(/\\/g, '/');
2808
+ return `- [\`${pkg.name}\`](${rel})`;
2809
+ })
2810
+ .join('\n');
2811
+ }
2812
+ function buildAgentsMdPreamble() {
2813
+ return `# AI Agent Documentation — Optimizely DXP Packages
2814
+
2815
+ The section below is auto-generated by \`opti-cms project:ai\`. Re-run after installing or upgrading \`@remkoj\` packages.
2816
+
2817
+ `;
2818
+ }
2819
+ function buildAgentsMdSection(packages, projectPath) {
2820
+ return `The following \`@remkoj\` packages are installed. Read their \`AGENTS.md\` for documentation:\n\n${buildReferenceList(packages, projectPath)}`;
2821
+ }
2822
+ /**
2823
+ * Creates or updates the auto-generated section delimited by SECTION_START/SECTION_END markers.
2824
+ *
2825
+ * - File absent: creates it with preamble + section.
2826
+ * - File present with markers: replaces the section in-place.
2827
+ * - File present without markers: appends the section (preserves existing content).
2828
+ * - force=true: always replaces the entire file with preamble + section.
2829
+ */
2830
+ async function updateWithSectionMarkers(filePath, sectionContent, preamble, force, projectPath) {
2831
+ const rel = path.relative(projectPath, filePath);
2832
+ const section = wrapSection(sectionContent);
2833
+ let existing;
2834
+ try {
2835
+ existing = await fs$1.readFile(filePath, 'utf-8');
2836
+ }
2837
+ catch {
2838
+ // File does not exist — will be created
2839
+ }
2840
+ if (!existing || force) {
2841
+ await fs$1.writeFile(filePath, preamble + section + '\n', 'utf-8');
2842
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} ${existing ? 'Replaced' : 'Created'} ${rel}\n`);
2843
+ return;
2844
+ }
2845
+ if (existing.includes(SECTION_START) && existing.includes(SECTION_END)) {
2846
+ const before = existing.slice(0, existing.indexOf(SECTION_START));
2847
+ const after = existing.slice(existing.indexOf(SECTION_END) + SECTION_END.length);
2848
+ await fs$1.writeFile(filePath, before + section + after, 'utf-8');
2849
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} Updated section in ${rel}\n`);
2850
+ }
2851
+ else {
2852
+ await fs$1.writeFile(filePath, existing.trimEnd() + '\n\n' + section + '\n', 'utf-8');
2853
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} Appended section to ${rel}\n`);
2854
+ }
2855
+ }
2856
+ /**
2857
+ * Writes a file only when it does not already exist.
2858
+ * When force=true the file is always (re)written.
2859
+ */
2860
+ async function writeNewFileOnly(filePath, content, force, projectPath) {
2861
+ const rel = path.relative(projectPath, filePath);
2862
+ let exists = false;
2863
+ try {
2864
+ await fs$1.access(filePath);
2865
+ exists = true;
2866
+ }
2867
+ catch {
2868
+ // File does not exist
2869
+ }
2870
+ if (exists && !force) {
2871
+ process.stdout.write(` ${chalk.yellowBright(figures.info)} ${rel} already exists — use --force to overwrite\n`);
2872
+ return;
2873
+ }
2874
+ await fs$1.writeFile(filePath, content, 'utf-8');
2875
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} ${exists ? 'Replaced' : 'Created'} ${rel}\n`);
2876
+ }
2877
+ /**
2878
+ * Writes one `.vscode/instructions/remkoj-<name>.instructions.md` file per package,
2879
+ * each containing a `#file:` reference to the package's AGENTS.md in node_modules.
2880
+ * VS Code Copilot resolves `#file:` references at query time, so content stays
2881
+ * current after a package upgrade without re-running this command.
2882
+ *
2883
+ * Also removes the deprecated `github.copilot.chat.codeGeneration.instructions`
2884
+ * key from `.vscode/settings.json` if it was written by a previous run.
2885
+ */
2886
+ async function updateCopilotInstructions(projectPath, packages) {
2887
+ const instructionsDir = path.join(projectPath, '.vscode', 'instructions');
2888
+ await ensureDir(instructionsDir);
2889
+ // Remove stale remkoj instruction files from previous runs
2890
+ const FILE_PREFIX = 'remkoj-';
2891
+ try {
2892
+ const existing = await fs$1.readdir(instructionsDir);
2893
+ for (const f of existing) {
2894
+ if (f.startsWith(FILE_PREFIX) && f.endsWith('.instructions.md')) {
2895
+ await fs$1.unlink(path.join(instructionsDir, f));
2896
+ }
2897
+ }
2898
+ }
2899
+ catch {
2900
+ // Directory unreadable — ignore
2901
+ }
2902
+ // Write one instruction file per package using a #file: reference
2903
+ for (const pkg of packages) {
2904
+ const safeName = pkg.name.replace('@remkoj/', '');
2905
+ const filePath = path.join(instructionsDir, `${FILE_PREFIX}${safeName}.instructions.md`);
2906
+ const ref = path.relative(projectPath, pkg.agentsPath).replace(/\\/g, '/');
2907
+ const content = `---\napplyTo: "**"\n---\n\n#file:${ref}\n`;
2908
+ await fs$1.writeFile(filePath, content, 'utf-8');
2909
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} Wrote ${path.relative(projectPath, filePath)}\n`);
2910
+ }
2911
+ // Clean up the deprecated settings key written by previous versions of this command
2912
+ await removeDeprecatedCopilotSetting(projectPath);
2913
+ }
2914
+ async function removeDeprecatedCopilotSetting(projectPath) {
2915
+ const settingsFile = path.join(projectPath, '.vscode', 'settings.json');
2916
+ let settings;
2917
+ try {
2918
+ settings = JSON.parse(await fs$1.readFile(settingsFile, 'utf-8'));
2919
+ }
2920
+ catch {
2921
+ return; // File absent or invalid — nothing to clean up
2922
+ }
2923
+ const key = 'github.copilot.chat.codeGeneration.instructions';
2924
+ if (!(key in settings))
2925
+ return;
2926
+ const remaining = settings[key].filter((e) => !e.file?.startsWith('node_modules/@remkoj/'));
2927
+ if (remaining.length === 0) {
2928
+ delete settings[key];
2929
+ }
2930
+ else {
2931
+ settings[key] = remaining;
2932
+ }
2933
+ await fs$1.writeFile(settingsFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
2934
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} Removed deprecated setting from ${path.relative(projectPath, settingsFile)}\n`);
2935
+ }
2936
+ /** Writes the Cursor rules file with a reference list; always regenerated since it is fully derived from node_modules. */
2937
+ async function writeCursorRules(filePath, packages, projectPath) {
2938
+ const rel = path.relative(projectPath, filePath);
2939
+ let exists = false;
2940
+ try {
2941
+ await fs$1.access(filePath);
2942
+ exists = true;
2943
+ }
2944
+ catch {
2945
+ // File does not exist
2946
+ }
2947
+ const list = buildReferenceList(packages, projectPath);
2948
+ const content = [
2949
+ '---',
2950
+ 'description: Documentation for @remkoj Optimizely DXP packages installed in this project',
2951
+ 'alwaysApply: true',
2952
+ '---',
2953
+ '',
2954
+ wrapSection(`The following \`@remkoj\` packages are installed. Read their \`AGENTS.md\` for documentation:\n\n${list}`),
2955
+ '',
2956
+ ].join('\n');
2957
+ await fs$1.writeFile(filePath, content, 'utf-8');
2958
+ process.stdout.write(` ${chalk.greenBright(figures.tick)} ${exists ? 'Updated' : 'Created'} ${rel}\n`);
2959
+ }
2960
+ async function ensureDir(dirPath) {
2961
+ await fs$1.mkdir(dirPath, { recursive: true });
2962
+ }
2963
+
2964
+ var version = "6.0.0-rc.2";
2220
2965
  var name = "opti-cms";
2221
2966
  var APP = {
2222
2967
  version: version,
@@ -2233,15 +2978,6 @@ const CmsVersionCommand = {
2233
2978
  const client = createCmsClient(args);
2234
2979
  if (client.debug)
2235
2980
  process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Reading version information Optimizely CMS\n`));
2236
- const versionInfo = await client.getInstanceInfo().catch((error) => {
2237
- switch (error.message) {
2238
- case "fetch failed":
2239
- throw new Error("Unable to connect to Optimizely CMS, please verify that the CMS URL is correct");
2240
- default:
2241
- throw error;
2242
- }
2243
- });
2244
- const hasPreview2Info = versionInfo.results.preview2Data?.baseUrl ? true : false;
2245
2981
  const info = new Table({
2246
2982
  head: [
2247
2983
  chalk.yellow(chalk.bold("Component")),
@@ -2250,18 +2986,10 @@ const CmsVersionCommand = {
2250
2986
  colWidths: [30, 60],
2251
2987
  colAligns: ["left", "left"]
2252
2988
  });
2253
- 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']);
2989
+ info.push(["Base URL", client.cmsUrl.href]);
2256
2990
  info.push(["Client API", client.apiVersion]);
2257
- info.push(["Service API", versionInfo.apiVersion]);
2258
- if (hasPreview2Info)
2259
- info.push(["Service API (Preview 2)", versionInfo.results.preview2Data?.apiVersion ?? 'n/a']);
2260
- info.push(["CMS Build", versionInfo.cmsVersion]);
2261
- info.push(["Service Build", versionInfo.serviceVersion]);
2262
2991
  info.push(["SDK", APP.version]);
2263
2992
  process.stdout.write(info.toString() + "\n");
2264
- process.stdout.write(chalk.yellowBright(`${figures.arrowRight} Optimizely CMS Status: ${versionInfo.status}\n`));
2265
2993
  process.stdout.write(chalk.green(chalk.bold(figures.tick + " Done")) + "\n");
2266
2994
  }
2267
2995
  };
@@ -2318,7 +3046,7 @@ const CmsResetCommand = {
2318
3046
  });
2319
3047
  if (!didRemoveCustomProperties) {
2320
3048
  const ctUrl = new URL('/ui/EPiServer.Cms.UI.Admin/default#/ContentTypes', client.cmsUrl);
2321
- process.stdout.write(`${chalk.redBright(figures.cross)} Custom properties on the \"Blank Section\" and \"Blank Experience\" Content Types must be removed manually, please remove and restart command.\n`);
3049
+ process.stdout.write(`${chalk.redBright(figures.cross)} Custom properties on the "Blank Section" and "Blank Experience" Content Types must be removed manually, please remove and restart command.\n`);
2322
3050
  process.stdout.write(` ${figures.arrowRight} Content Type manager: ${ctUrl}\n`);
2323
3051
  process.exit(1);
2324
3052
  }
@@ -2352,7 +3080,7 @@ async function deleteContentItem(client, key, onlyChildren = false) {
2352
3080
  const children = await getContentItemChildren(client, key);
2353
3081
  let hasError = false;
2354
3082
  let removedCount = 0;
2355
- for (let child of children.items)
3083
+ for (const child of children.items)
2356
3084
  if (child.key) {
2357
3085
  removedCount = removedCount + await deleteContentItem(client, child.key).catch(e => {
2358
3086
  console.error(e);
@@ -2360,7 +3088,7 @@ async function deleteContentItem(client, key, onlyChildren = false) {
2360
3088
  return 0;
2361
3089
  });
2362
3090
  }
2363
- for (let child of children.assets)
3091
+ for (const child of children.assets)
2364
3092
  if (child.key) {
2365
3093
  removedCount = removedCount + await deleteContentItem(client, child.key).catch(e => {
2366
3094
  console.error(e);
@@ -2379,7 +3107,7 @@ async function deleteContentItem(client, key, onlyChildren = false) {
2379
3107
  process.stderr.write(chalk.redBright(` ${figures.cross}The ContentItem ${key} is a reserved item, skipping deletion\n`));
2380
3108
  return removedCount;
2381
3109
  }
2382
- const deleteResult = await client.preview2ContentDelete({ path: { key }, query: { permanent: true } }).then(r => r.key).catch((e) => {
3110
+ const deleteResult = await client.contentDelete({ path: { key }, headers: { "cms-permanent-delete": true } }).then(r => r ? r.key : key).catch((e) => {
2383
3111
  if (e.status == 404)
2384
3112
  return key;
2385
3113
  throw e;
@@ -2403,7 +3131,6 @@ async function resetSystemTypes(client) {
2403
3131
  const newType = await client.contentTypesPatch({
2404
3132
  path: { key: systemType },
2405
3133
  body: {
2406
- key: systemType,
2407
3134
  properties: {}
2408
3135
  }
2409
3136
  }).catch((e) => e.status == 404 ? undefined : null);
@@ -2415,7 +3142,7 @@ async function resetSystemTypes(client) {
2415
3142
  async function removeContentTypes(client) {
2416
3143
  const contentTypes = await getAllTypes(client);
2417
3144
  let removedCount = 0;
2418
- for (let contentType of contentTypes.filter(item => item.source == '' && !reservedTypes.includes(item.key))) {
3145
+ for (const contentType of contentTypes.filter(item => item.source == '' && !reservedTypes.includes(item.key))) {
2419
3146
  if (client.debug)
2420
3147
  process.stdout.write(` ${chalk.blueBright(figures.arrowRight)} Removing content type ${contentType.displayName} (${contentType.key})\n`);
2421
3148
  const result = await client.contentTypesDelete({ path: { key: contentType.key } }).catch((e) => {
@@ -2456,7 +3183,7 @@ async function getAllTemplates(client, batchSize = 100) {
2456
3183
  }
2457
3184
  throw e;
2458
3185
  });
2459
- const totalItemCount = items.totalItemCount ?? items.items?.length ?? 0;
3186
+ const totalItemCount = items.totalCount ?? items.totalItemCount ?? items.items?.length ?? 0;
2460
3187
  const pageSize = items.pageSize ?? items.items?.length ?? 0;
2461
3188
  const actualItems = items.items ?? [];
2462
3189
  const pageCount = Math.ceil(totalItemCount / pageSize);
@@ -2486,7 +3213,7 @@ async function getAllTypes(client, batchSize = 100) {
2486
3213
  }
2487
3214
  throw e;
2488
3215
  });
2489
- const totalItemCount = items.totalItemCount ?? items.items?.length ?? 0;
3216
+ const totalItemCount = items.totalCount ?? items.totalItemCount ?? items.items?.length ?? 0;
2490
3217
  const pageSize = items.pageSize ?? items.items?.length ?? 0;
2491
3218
  const actualItems = items.items ?? [];
2492
3219
  const pageCount = Math.ceil(totalItemCount / pageSize);
@@ -2506,7 +3233,7 @@ async function getAllTypes(client, batchSize = 100) {
2506
3233
  return actualItems;
2507
3234
  }
2508
3235
  async function getAllAssets(client, parentKey, batchSize = 100) {
2509
- const items = await client.preview2ContentListAssets({ path: { key: parentKey }, query: { pageIndex: 0, pageSize: batchSize } }).catch((e) => {
3236
+ const items = await client.contentListAssets({ path: { key: parentKey }, query: { pageIndex: 0, pageSize: batchSize } }).catch((e) => {
2510
3237
  if (e.status == 404) {
2511
3238
  return {
2512
3239
  items: [],
@@ -2516,12 +3243,12 @@ async function getAllAssets(client, parentKey, batchSize = 100) {
2516
3243
  }
2517
3244
  throw e;
2518
3245
  });
2519
- const totalItemCount = items.totalItemCount ?? items.items?.length ?? 0;
3246
+ const totalItemCount = items.totalCount ?? items.totalItemCount ?? items.items?.length ?? 0;
2520
3247
  const pageSize = items.pageSize ?? items.items?.length ?? 0;
2521
3248
  const actualItems = items.items ?? [];
2522
3249
  const pageCount = Math.ceil(totalItemCount / pageSize);
2523
3250
  for (let pageNr = 1; pageNr < pageCount; pageNr++) {
2524
- const pageData = await client.preview2ContentListAssets({ path: { key: parentKey }, query: { pageIndex: pageNr, pageSize } }).catch((e) => {
3251
+ const pageData = await client.contentListAssets({ path: { key: parentKey }, query: { pageIndex: pageNr, pageSize } }).catch((e) => {
2525
3252
  if (e.status == 404) {
2526
3253
  return {
2527
3254
  items: [],
@@ -2536,7 +3263,7 @@ async function getAllAssets(client, parentKey, batchSize = 100) {
2536
3263
  return actualItems;
2537
3264
  }
2538
3265
  async function getAllItems(client, parentKey, batchSize = 100) {
2539
- const items = await client.preview2ContentListItems({ path: { key: parentKey }, query: { pageIndex: 0, pageSize: batchSize } }).catch((e) => {
3266
+ const items = await client.contentListItems({ path: { key: parentKey }, query: { pageIndex: 0, pageSize: batchSize } }).catch((e) => {
2540
3267
  if (e.status == 404) {
2541
3268
  return {
2542
3269
  items: [],
@@ -2546,12 +3273,12 @@ async function getAllItems(client, parentKey, batchSize = 100) {
2546
3273
  }
2547
3274
  throw e;
2548
3275
  });
2549
- const totalItemCount = items.totalItemCount ?? items.items?.length ?? 0;
3276
+ const totalItemCount = items.totalCount ?? items.totalItemCount ?? items.items?.length ?? 0;
2550
3277
  const pageSize = items.pageSize ?? items.items?.length ?? 0;
2551
3278
  const actualItems = items.items ?? [];
2552
3279
  const pageCount = Math.ceil(totalItemCount / pageSize);
2553
3280
  for (let pageNr = 1; pageNr < pageCount; pageNr++) {
2554
- const pageData = await client.preview2ContentListItems({ path: { key: parentKey }, query: { pageIndex: pageNr, pageSize } }).catch((e) => {
3281
+ const pageData = await client.contentListItems({ path: { key: parentKey }, query: { pageIndex: pageNr, pageSize } }).catch((e) => {
2555
3282
  if (e.status == 404) {
2556
3283
  return {
2557
3284
  items: [],
@@ -2584,20 +3311,24 @@ const commands = [
2584
3311
  StylesListCommand,
2585
3312
  StylesPullCommand,
2586
3313
  StylesPushCommand,
3314
+ StylesDeleteCommand,
2587
3315
  TypesPullCommand,
2588
- TypesPushCommand
3316
+ TypesPushCommand,
3317
+ MigrateCommand,
3318
+ ProjectAiCommand,
2589
3319
  ];
2590
3320
 
2591
3321
  async function main() {
2592
- const envFiles = prepare();
2593
- const app = createOptiCmsApp(APP.name, APP.version, undefined, envFiles);
3322
+ const projectDir = getProjectDir();
3323
+ const envFiles = prepare(projectDir);
3324
+ const app = createOptiCmsApp(APP.name, APP.version, undefined, envFiles, projectDir);
2594
3325
  app.command(commands);
2595
3326
  try {
2596
3327
  await app.parse(process.argv.slice(2));
2597
3328
  }
2598
3329
  catch {
2599
3330
  //We're ignoring error here, as yargs will already generate the "nice output" for it
2600
- //console.log('Caught error')
3331
+ //console.log ('Caught error')
2601
3332
  }
2602
3333
  }
2603
3334
  main();