@stackbit/cms-core 0.1.17-alpha.0 → 0.1.17-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  import _ from 'lodash';
2
2
  import slugify from 'slugify';
3
3
 
4
- import { Model } from '@stackbit/sdk';
5
- import { Model as CSIModel, Field, FieldObjectProps, FieldSpecificProps, UpdateOperationListFieldItem } from '@stackbit/types';
6
- import { mapPromise } from '@stackbit/utils';
4
+ import { Model as SDKModel } from '@stackbit/sdk';
5
+ import { Model as CSIModel, Field, FieldSpecificProps, UpdateOperationListFieldItem } from '@stackbit/types';
6
+ import { fieldPathToString, mapPromise } from '@stackbit/utils';
7
7
  import * as CSITypes from '@stackbit/types';
8
8
 
9
9
  import * as ContentStoreTypes from '../content-store-types';
@@ -91,15 +91,18 @@ export async function createDocumentRecursively({
91
91
  object,
92
92
  modelName,
93
93
  modelMap,
94
+ csiModelMap,
94
95
  createDocument
95
96
  }: {
96
97
  object?: Record<string, any>;
97
98
  modelName: string;
98
- modelMap: Record<string, Model>;
99
+ modelMap: Record<string, SDKModel>;
100
+ csiModelMap: Record<string, CSIModel>;
99
101
  createDocument: CreateDocumentCallback;
100
102
  }): Promise<{ document: CSITypes.Document; newRefDocuments: CSITypes.Document[] }> {
101
103
  const model = modelMap[modelName];
102
- if (!model) {
104
+ const csiModel = csiModelMap[modelName];
105
+ if (!model || !csiModel) {
103
106
  throw new Error(`no model with name '${modelName}' was found`);
104
107
  }
105
108
  if (model.type === 'page') {
@@ -111,11 +114,13 @@ export async function createDocumentRecursively({
111
114
  }
112
115
  }
113
116
 
114
- const nestedResult = await createNestedObjectRecursively({
117
+ const nestedResult = await createObjectRecursively({
115
118
  object,
116
119
  modelFields: model.fields ?? [],
117
- fieldPath: [],
120
+ csiModelFields: csiModel.fields ?? [],
121
+ fieldPath: [modelName],
118
122
  modelMap,
123
+ csiModelMap,
119
124
  createDocument
120
125
  });
121
126
 
@@ -140,17 +145,21 @@ function sanitizeSlug(slug: string) {
140
145
  .join('/');
141
146
  }
142
147
 
143
- async function createNestedObjectRecursively({
148
+ async function createObjectRecursively({
144
149
  object,
145
150
  modelFields,
151
+ csiModelFields,
146
152
  fieldPath,
147
153
  modelMap,
154
+ csiModelMap,
148
155
  createDocument
149
156
  }: {
150
157
  object?: Record<string, any>;
151
158
  modelFields: Field[];
159
+ csiModelFields: Field[];
152
160
  fieldPath: (string | number)[];
153
- modelMap: Record<string, Model>;
161
+ modelMap: Record<string, SDKModel>;
162
+ csiModelMap: Record<string, CSIModel>;
154
163
  createDocument: CreateDocumentCallback;
155
164
  }): Promise<{
156
165
  fields: Record<string, CSITypes.UpdateOperationField>;
@@ -173,6 +182,10 @@ async function createNestedObjectRecursively({
173
182
  const objectFieldNames = Object.keys(object);
174
183
  for (const modelField of modelFields) {
175
184
  const fieldName = modelField.name;
185
+ const csiModelField = csiModelFields.find((field) => field.name === fieldName);
186
+ if (!csiModelField) {
187
+ throw new Error(`no model field found for field at path ${fieldPathToString(fieldPath.concat(fieldName))}`);
188
+ }
176
189
  let value;
177
190
  if (fieldName in object) {
178
191
  // if the object has a field name matching a model field, use it
@@ -186,11 +199,13 @@ async function createNestedObjectRecursively({
186
199
  value = modelField.default;
187
200
  }
188
201
  if (!_.isNil(value)) {
189
- const fieldResult = await createNestedField({
202
+ const fieldResult = await createUpdateOperationFieldRecursively({
190
203
  value,
191
204
  modelField,
205
+ csiModelField,
192
206
  fieldPath: fieldPath.concat(fieldName),
193
207
  modelMap,
208
+ csiModelMap,
194
209
  createDocument
195
210
  });
196
211
  result.fields[fieldName] = fieldResult.field;
@@ -204,25 +219,34 @@ async function createNestedObjectRecursively({
204
219
  return result;
205
220
  }
206
221
 
207
- async function createNestedField({
222
+ async function createUpdateOperationFieldRecursively({
208
223
  value,
209
224
  modelField,
225
+ csiModelField,
210
226
  fieldPath,
211
227
  modelMap,
228
+ csiModelMap,
212
229
  createDocument
213
230
  }: {
214
231
  value: any;
215
232
  modelField: FieldSpecificProps;
233
+ csiModelField: FieldSpecificProps;
216
234
  fieldPath: (string | number)[];
217
- modelMap: Record<string, Model>;
235
+ modelMap: Record<string, SDKModel>;
236
+ csiModelMap: Record<string, CSIModel>;
218
237
  createDocument: CreateDocumentCallback;
219
238
  }): Promise<{ field: CSITypes.UpdateOperationField; newRefDocuments: CSITypes.Document[] }> {
220
- if (modelField.type === 'object') {
221
- const result = await createNestedObjectRecursively({
239
+ if (csiModelField.type === 'object') {
240
+ if (modelField.type !== 'object') {
241
+ throw new Error(`field type mismatch between content-source and mapped models at field path ${fieldPathToString(fieldPath)}`);
242
+ }
243
+ const result = await createObjectRecursively({
222
244
  object: value,
223
245
  modelFields: modelField.fields,
246
+ csiModelFields: csiModelField.fields,
224
247
  fieldPath,
225
248
  modelMap,
249
+ csiModelMap,
226
250
  createDocument
227
251
  });
228
252
  return {
@@ -232,7 +256,10 @@ async function createNestedField({
232
256
  },
233
257
  newRefDocuments: result.newRefDocuments
234
258
  };
235
- } else if (modelField.type === 'model') {
259
+ } else if (csiModelField.type === 'model') {
260
+ if (modelField.type !== 'model') {
261
+ throw new Error(`field type mismatch between content-source and mapped models at field path ${fieldPathToString(fieldPath)}`);
262
+ }
236
263
  let { $$type, ...rest } = value;
237
264
  const modelNames = modelField.models;
238
265
  // for backward compatibility check if the object has 'type' instead of '$$type' because older projects use
@@ -246,14 +273,17 @@ async function createNestedField({
246
273
  throw new Error(`no $$type was specified for nested model`);
247
274
  }
248
275
  const model = modelMap[modelName];
249
- if (!model) {
276
+ const csiModel = csiModelMap[modelName];
277
+ if (!model || !csiModel) {
250
278
  throw new Error(`no model with name '${modelName}' was found`);
251
279
  }
252
- const result = await createNestedObjectRecursively({
280
+ const result = await createObjectRecursively({
253
281
  object: rest,
254
282
  modelFields: model.fields ?? [],
283
+ csiModelFields: csiModel.fields ?? [],
255
284
  fieldPath,
256
285
  modelMap,
286
+ csiModelMap,
257
287
  createDocument
258
288
  });
259
289
  return {
@@ -264,7 +294,7 @@ async function createNestedField({
264
294
  },
265
295
  newRefDocuments: result.newRefDocuments
266
296
  };
267
- } else if (modelField.type === 'image') {
297
+ } else if (csiModelField.type === 'image') {
268
298
  let refId: string | undefined;
269
299
  // TODO: if modelField.source is cloudinary, the new document field
270
300
  // should be of the 'image' type with 'title' and 'url' properties
@@ -284,7 +314,10 @@ async function createNestedField({
284
314
  },
285
315
  newRefDocuments: []
286
316
  };
287
- } else if (modelField.type === 'reference') {
317
+ } else if (csiModelField.type === 'reference') {
318
+ if (modelField.type !== 'reference') {
319
+ throw new Error(`field type mismatch between content-source and mapped models at field path ${fieldPathToString(fieldPath)}`);
320
+ }
288
321
  let { $$ref: refId = null, $$type: modelName = null, ...rest } = _.isPlainObject(value) ? value : { $$ref: value };
289
322
  if (refId) {
290
323
  return {
@@ -311,6 +344,7 @@ async function createNestedField({
311
344
  object: rest,
312
345
  modelName,
313
346
  modelMap,
347
+ csiModelMap,
314
348
  createDocument
315
349
  });
316
350
  return {
@@ -322,22 +356,28 @@ async function createNestedField({
322
356
  newRefDocuments: [document, ...newRefDocuments]
323
357
  };
324
358
  }
325
- } else if (modelField.type === 'list') {
359
+ } else if (csiModelField.type === 'list') {
360
+ if (modelField.type !== 'list') {
361
+ throw new Error(`field type mismatch between external and internal models at field path ${fieldPathToString(fieldPath)}`);
362
+ }
326
363
  if (!Array.isArray(value)) {
327
364
  throw new Error(`value for list field must be array`);
328
365
  }
329
366
  const itemsField = modelField.items;
330
- if (!itemsField) {
367
+ const csiItemsField = csiModelField.items;
368
+ if (!itemsField || !csiItemsField) {
331
369
  throw new Error(`list field does not define items`);
332
370
  }
333
371
  const arrayResult = await mapPromise(
334
372
  value,
335
373
  async (item, index): Promise<{ field: UpdateOperationListFieldItem; newRefDocuments: CSITypes.Document[] }> => {
336
- const result = await createNestedField({
374
+ const result = await createUpdateOperationFieldRecursively({
337
375
  value: item,
338
376
  modelField: itemsField,
377
+ csiModelField: csiItemsField,
339
378
  fieldPath: fieldPath.concat(index),
340
379
  modelMap,
380
+ csiModelMap,
341
381
  createDocument
342
382
  });
343
383
  if (result.field.type === 'list') {
@@ -356,10 +396,22 @@ async function createNestedField({
356
396
  },
357
397
  newRefDocuments: arrayResult.reduce((result: CSITypes.Document[], { newRefDocuments }) => result.concat(newRefDocuments), [])
358
398
  };
399
+ } else if (
400
+ (csiModelField.type === 'string' || csiModelField.type === 'text') &&
401
+ (modelField.type === 'json' || modelField.type === 'style') &&
402
+ _.isPlainObject(value)
403
+ ) {
404
+ return {
405
+ field: {
406
+ type: csiModelField.type,
407
+ value: JSON.stringify(value)
408
+ },
409
+ newRefDocuments: []
410
+ };
359
411
  }
360
412
  return {
361
413
  field: {
362
- type: modelField.type,
414
+ type: csiModelField.type,
363
415
  value: value
364
416
  },
365
417
  newRefDocuments: []
@@ -370,24 +422,31 @@ export async function convertOperationField({
370
422
  operationField,
371
423
  fieldPath,
372
424
  modelField,
425
+ csiModelField,
373
426
  modelMap,
427
+ csiModelMap,
374
428
  createDocument
375
429
  }: {
376
430
  operationField: ContentStoreTypes.UpdateOperationField;
377
431
  fieldPath: (string | number)[];
378
- modelField: Field;
379
- modelMap: Record<string, Model>;
432
+ modelField: FieldSpecificProps;
433
+ csiModelField: FieldSpecificProps;
434
+ modelMap: Record<string, SDKModel>;
435
+ csiModelMap: Record<string, SDKModel>;
380
436
  createDocument: CreateDocumentCallback;
381
437
  }): Promise<CSITypes.UpdateOperationField> {
382
- // for insert operations, the modelField will be of the list, so get the modelField of the list items
383
- const modelFieldOrListItems: FieldSpecificProps = modelField.type === 'list' ? modelField.items! : modelField;
384
438
  switch (operationField.type) {
385
439
  case 'object': {
386
- const result = await createNestedObjectRecursively({
440
+ if (modelField.type !== 'object' || csiModelField.type !== 'object') {
441
+ throw new Error(`the operation field type 'object' does not match the model field type '${modelField.type}'`);
442
+ }
443
+ const result = await createObjectRecursively({
387
444
  object: operationField.object,
388
- modelFields: (modelFieldOrListItems as FieldObjectProps).fields,
389
- fieldPath: fieldPath,
445
+ modelFields: modelField.fields,
446
+ csiModelFields: csiModelField.fields,
447
+ fieldPath,
390
448
  modelMap,
449
+ csiModelMap,
391
450
  createDocument
392
451
  });
393
452
  return {
@@ -397,14 +456,17 @@ export async function convertOperationField({
397
456
  }
398
457
  case 'model': {
399
458
  const model = modelMap[operationField.modelName];
400
- if (!model) {
459
+ const csiModel = csiModelMap[operationField.modelName];
460
+ if (!model || !csiModel) {
401
461
  throw new Error(`error updating document, could not find document model: '${operationField.modelName}'`);
402
462
  }
403
- const result = await createNestedObjectRecursively({
463
+ const result = await createObjectRecursively({
404
464
  object: operationField.object,
405
- modelFields: model.fields!,
465
+ modelFields: model.fields ?? [],
466
+ csiModelFields: csiModel.fields ?? [],
406
467
  fieldPath,
407
468
  modelMap,
469
+ csiModelMap,
408
470
  createDocument
409
471
  });
410
472
  return {
@@ -413,18 +475,22 @@ export async function convertOperationField({
413
475
  fields: result.fields
414
476
  };
415
477
  }
478
+ case 'reference':
479
+ return operationField;
416
480
  case 'list': {
417
- if (modelField.type !== 'list') {
418
- throw new Error(`'the operation field type '${operationField.type}' does not match the model field type '${modelField.type}'`);
481
+ if (modelField.type !== 'list' || csiModelField.type !== 'list') {
482
+ throw new Error(`the operation field type '${operationField.type}' does not match the model field type '${modelField.type}'`);
419
483
  }
420
484
  const result: UpdateOperationListFieldItem[] = await mapPromise(
421
485
  operationField.items,
422
- async (item): Promise<UpdateOperationListFieldItem> => {
423
- const result = await createNestedField({
486
+ async (item, index): Promise<UpdateOperationListFieldItem> => {
487
+ const result = await createUpdateOperationFieldRecursively({
424
488
  value: item,
425
- modelField: modelField.items!,
426
- fieldPath,
489
+ modelField: modelField.items,
490
+ csiModelField: csiModelField.items,
491
+ fieldPath: fieldPath.concat(index),
427
492
  modelMap,
493
+ csiModelMap,
428
494
  createDocument
429
495
  });
430
496
  if (result.field.type === 'list') {
@@ -438,34 +504,48 @@ export async function convertOperationField({
438
504
  items: result
439
505
  };
440
506
  }
441
- case 'string':
442
- // When inserting new string value into a list, the client does not
443
- // send value. Set an empty string value.
444
- if (typeof operationField.value !== 'string') {
445
- return {
446
- type: operationField.type,
447
- value: ''
448
- };
449
- }
450
- return operationField as CSITypes.UpdateOperationField;
451
507
  case 'enum':
508
+ if (csiModelField.type !== 'enum' && csiModelField.type !== 'string') {
509
+ throw new Error(`the operation field type 'enum' can be performed on 'string' and 'enum' content-source field types '${csiModelField.type}'`);
510
+ }
452
511
  // When inserting new enum value into a list, the client does not
453
512
  // send value. Set first option as the value.
454
513
  if (typeof operationField.value !== 'string') {
455
- if (modelFieldOrListItems.type !== 'enum') {
456
- throw new Error(`'the operation field type 'enum' does not match the model field type '${modelFieldOrListItems.type}'`);
514
+ if (modelField.type !== 'enum') {
515
+ throw new Error(`the operation field type 'enum' does not match the model field type '${modelField.type}'`);
457
516
  }
458
- const option = modelFieldOrListItems.options[0]!;
517
+ const option = modelField.options[0]!;
459
518
  const optionValue = typeof option === 'object' ? option.value : option;
460
519
  return {
461
- type: operationField.type,
520
+ type: csiModelField.type,
462
521
  value: optionValue
463
522
  };
464
523
  }
465
- return operationField as CSITypes.UpdateOperationField;
466
- case 'image':
467
- return operationField as CSITypes.UpdateOperationField;
468
- default:
469
- return operationField as CSITypes.UpdateOperationField;
524
+ return {
525
+ type: csiModelField.type,
526
+ value: operationField.value
527
+ };
528
+ case 'string':
529
+ // When inserting new string value into a list, the client does not
530
+ // send value. Set an empty string value.
531
+ if (typeof operationField.value !== 'string') {
532
+ return {
533
+ type: operationField.type,
534
+ value: ''
535
+ };
536
+ }
537
+ return operationField;
538
+ default: {
539
+ const result = await createUpdateOperationFieldRecursively({
540
+ value: operationField.value,
541
+ modelField,
542
+ csiModelField,
543
+ fieldPath,
544
+ modelMap,
545
+ csiModelMap,
546
+ createDocument
547
+ });
548
+ return result.field;
549
+ }
470
550
  }
471
551
  }
@@ -166,12 +166,6 @@ function mapCSIFieldsToStoreFields({
166
166
  localized: modelField.localized,
167
167
  context
168
168
  });
169
- // Override document field types with specific model field types.
170
- // For example when developer re-mapped content-source model "string"
171
- // field to stackbit "color" field.
172
- if (modelField.type === 'color' || modelField.type === 'style') {
173
- docField.type = modelField.type;
174
- }
175
169
  docField.label = modelField.label;
176
170
  result[modelField.name] = docField;
177
171
  return result;
@@ -201,8 +195,34 @@ function mapCSIFieldToStoreField({
201
195
  })
202
196
  } as ContentStoreTypes.DocumentField;
203
197
  }
204
- // TODO: check if need to add "options" to "enum" and subtype/min/max to "number"
205
198
  switch (modelField.type) {
199
+ case 'string':
200
+ case 'text':
201
+ case 'html':
202
+ case 'url':
203
+ case 'boolean':
204
+ case 'number':
205
+ case 'date':
206
+ case 'datetime':
207
+ case 'enum':
208
+ case 'json':
209
+ case 'style':
210
+ case 'color':
211
+ case 'slug':
212
+ // Override document field types with model field types.
213
+ // Developer can remap content-source model fields to different field using stackbit config.
214
+ // For example, a 'string' field in a content-source can be mapped to 'color' field in stackbit config.
215
+ return {
216
+ ...csiDocumentField,
217
+ type: modelField.type
218
+ } as ContentStoreTypes.DocumentField;
219
+ // Don't override types of the following document fields.
220
+ // An 'image' model field can be a 'reference' document field in CMSes like Sanity and Contentful.
221
+ // Rest of the fields must have the same type across document and model fields.
222
+ case 'image':
223
+ case 'file':
224
+ case 'reference':
225
+ return csiDocumentField as ContentStoreTypes.DocumentField;
206
226
  case 'object':
207
227
  return mapObjectField(csiDocumentField as CSITypes.DocumentObjectField, modelField, context);
208
228
  case 'model':
@@ -214,8 +234,10 @@ function mapCSIFieldToStoreField({
214
234
  return mapRichTextField(csiDocumentField as CSITypes.DocumentRichTextField);
215
235
  case 'markdown':
216
236
  return mapMarkdownField(csiDocumentField as CSITypes.DocumentValueField);
217
- default:
218
- return csiDocumentField as ContentStoreTypes.DocumentField;
237
+ default: {
238
+ const _exhaustiveCheck: never = modelField;
239
+ return _exhaustiveCheck;
240
+ }
219
241
  }
220
242
  }
221
243
 
@@ -60,8 +60,8 @@ export function validateModels<T extends Model>({ models, logger }: { models: T[
60
60
  const { config, errors } = validateConfig({
61
61
  stackbitVersion: '0.5.0',
62
62
  models: models,
63
- dirPath: '',
64
- filePath: ''
63
+ dirPath: '.',
64
+ filePath: 'stackbit.config.js'
65
65
  });
66
66
 
67
67
  for (const error of errors) {
@@ -186,10 +186,18 @@ function sanitizeAndGroupSiteMapEntries(siteMapEntries: SiteMapEntry[]): SiteMap
186
186
 
187
187
  function getSiteMapGroupKey(siteMapEntry: SiteMapEntry): string {
188
188
  return 'document' in siteMapEntry
189
- ? `${siteMapEntry.document.srcType}:${siteMapEntry.document.srcProjectId}:${siteMapEntry.document.id}`
189
+ ? getSiteMapGroupKeyForDocument({
190
+ srcType: siteMapEntry.document.srcType,
191
+ srcProjectId: siteMapEntry.document.srcProjectId,
192
+ srcDocumentId: siteMapEntry.document.id
193
+ })
190
194
  : SiteMapStaticEntriesKey.toString();
191
195
  }
192
196
 
197
+ export function getSiteMapGroupKeyForDocument({ srcType, srcProjectId, srcDocumentId }: { srcType: string; srcProjectId: string; srcDocumentId: string }): string {
198
+ return `${srcType}:${srcProjectId}:${srcDocumentId}`;
199
+ }
200
+
193
201
  export function getDocumentFieldLabelValueForSiteMapEntry({
194
202
  siteMapEntry,
195
203
  locale,