@stackbit/cms-core 0.1.16 → 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';
@@ -19,11 +19,13 @@ export type CreateDocumentCallback = ({
19
19
  export function getCreateDocumentThunk({
20
20
  csiModelMap,
21
21
  locale,
22
+ defaultLocaleDocumentId,
22
23
  userContext,
23
24
  contentSourceInstance
24
25
  }: {
25
26
  csiModelMap: Record<string, CSIModel>;
26
27
  locale?: string;
28
+ defaultLocaleDocumentId?: string;
27
29
  userContext: unknown;
28
30
  contentSourceInstance: CSITypes.ContentSourceInterface;
29
31
  }): CreateDocumentCallback {
@@ -41,6 +43,7 @@ export function getCreateDocumentThunk({
41
43
  model: csiModel,
42
44
  modelMap: csiModelMap,
43
45
  locale,
46
+ defaultLocaleDocumentId,
44
47
  userContext
45
48
  });
46
49
  };
@@ -88,15 +91,18 @@ export async function createDocumentRecursively({
88
91
  object,
89
92
  modelName,
90
93
  modelMap,
94
+ csiModelMap,
91
95
  createDocument
92
96
  }: {
93
97
  object?: Record<string, any>;
94
98
  modelName: string;
95
- modelMap: Record<string, Model>;
99
+ modelMap: Record<string, SDKModel>;
100
+ csiModelMap: Record<string, CSIModel>;
96
101
  createDocument: CreateDocumentCallback;
97
102
  }): Promise<{ document: CSITypes.Document; newRefDocuments: CSITypes.Document[] }> {
98
103
  const model = modelMap[modelName];
99
- if (!model) {
104
+ const csiModel = csiModelMap[modelName];
105
+ if (!model || !csiModel) {
100
106
  throw new Error(`no model with name '${modelName}' was found`);
101
107
  }
102
108
  if (model.type === 'page') {
@@ -108,11 +114,13 @@ export async function createDocumentRecursively({
108
114
  }
109
115
  }
110
116
 
111
- const nestedResult = await createNestedObjectRecursively({
117
+ const nestedResult = await createObjectRecursively({
112
118
  object,
113
119
  modelFields: model.fields ?? [],
114
- fieldPath: [],
120
+ csiModelFields: csiModel.fields ?? [],
121
+ fieldPath: [modelName],
115
122
  modelMap,
123
+ csiModelMap,
116
124
  createDocument
117
125
  });
118
126
 
@@ -137,17 +145,21 @@ function sanitizeSlug(slug: string) {
137
145
  .join('/');
138
146
  }
139
147
 
140
- async function createNestedObjectRecursively({
148
+ async function createObjectRecursively({
141
149
  object,
142
150
  modelFields,
151
+ csiModelFields,
143
152
  fieldPath,
144
153
  modelMap,
154
+ csiModelMap,
145
155
  createDocument
146
156
  }: {
147
157
  object?: Record<string, any>;
148
158
  modelFields: Field[];
159
+ csiModelFields: Field[];
149
160
  fieldPath: (string | number)[];
150
- modelMap: Record<string, Model>;
161
+ modelMap: Record<string, SDKModel>;
162
+ csiModelMap: Record<string, CSIModel>;
151
163
  createDocument: CreateDocumentCallback;
152
164
  }): Promise<{
153
165
  fields: Record<string, CSITypes.UpdateOperationField>;
@@ -170,6 +182,10 @@ async function createNestedObjectRecursively({
170
182
  const objectFieldNames = Object.keys(object);
171
183
  for (const modelField of modelFields) {
172
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
+ }
173
189
  let value;
174
190
  if (fieldName in object) {
175
191
  // if the object has a field name matching a model field, use it
@@ -183,11 +199,13 @@ async function createNestedObjectRecursively({
183
199
  value = modelField.default;
184
200
  }
185
201
  if (!_.isNil(value)) {
186
- const fieldResult = await createNestedField({
202
+ const fieldResult = await createUpdateOperationFieldRecursively({
187
203
  value,
188
204
  modelField,
205
+ csiModelField,
189
206
  fieldPath: fieldPath.concat(fieldName),
190
207
  modelMap,
208
+ csiModelMap,
191
209
  createDocument
192
210
  });
193
211
  result.fields[fieldName] = fieldResult.field;
@@ -201,25 +219,34 @@ async function createNestedObjectRecursively({
201
219
  return result;
202
220
  }
203
221
 
204
- async function createNestedField({
222
+ async function createUpdateOperationFieldRecursively({
205
223
  value,
206
224
  modelField,
225
+ csiModelField,
207
226
  fieldPath,
208
227
  modelMap,
228
+ csiModelMap,
209
229
  createDocument
210
230
  }: {
211
231
  value: any;
212
232
  modelField: FieldSpecificProps;
233
+ csiModelField: FieldSpecificProps;
213
234
  fieldPath: (string | number)[];
214
- modelMap: Record<string, Model>;
235
+ modelMap: Record<string, SDKModel>;
236
+ csiModelMap: Record<string, CSIModel>;
215
237
  createDocument: CreateDocumentCallback;
216
238
  }): Promise<{ field: CSITypes.UpdateOperationField; newRefDocuments: CSITypes.Document[] }> {
217
- if (modelField.type === 'object') {
218
- 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({
219
244
  object: value,
220
245
  modelFields: modelField.fields,
246
+ csiModelFields: csiModelField.fields,
221
247
  fieldPath,
222
248
  modelMap,
249
+ csiModelMap,
223
250
  createDocument
224
251
  });
225
252
  return {
@@ -229,7 +256,10 @@ async function createNestedField({
229
256
  },
230
257
  newRefDocuments: result.newRefDocuments
231
258
  };
232
- } 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
+ }
233
263
  let { $$type, ...rest } = value;
234
264
  const modelNames = modelField.models;
235
265
  // for backward compatibility check if the object has 'type' instead of '$$type' because older projects use
@@ -243,14 +273,17 @@ async function createNestedField({
243
273
  throw new Error(`no $$type was specified for nested model`);
244
274
  }
245
275
  const model = modelMap[modelName];
246
- if (!model) {
276
+ const csiModel = csiModelMap[modelName];
277
+ if (!model || !csiModel) {
247
278
  throw new Error(`no model with name '${modelName}' was found`);
248
279
  }
249
- const result = await createNestedObjectRecursively({
280
+ const result = await createObjectRecursively({
250
281
  object: rest,
251
282
  modelFields: model.fields ?? [],
283
+ csiModelFields: csiModel.fields ?? [],
252
284
  fieldPath,
253
285
  modelMap,
286
+ csiModelMap,
254
287
  createDocument
255
288
  });
256
289
  return {
@@ -261,7 +294,7 @@ async function createNestedField({
261
294
  },
262
295
  newRefDocuments: result.newRefDocuments
263
296
  };
264
- } else if (modelField.type === 'image') {
297
+ } else if (csiModelField.type === 'image') {
265
298
  let refId: string | undefined;
266
299
  // TODO: if modelField.source is cloudinary, the new document field
267
300
  // should be of the 'image' type with 'title' and 'url' properties
@@ -281,7 +314,10 @@ async function createNestedField({
281
314
  },
282
315
  newRefDocuments: []
283
316
  };
284
- } 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
+ }
285
321
  let { $$ref: refId = null, $$type: modelName = null, ...rest } = _.isPlainObject(value) ? value : { $$ref: value };
286
322
  if (refId) {
287
323
  return {
@@ -308,6 +344,7 @@ async function createNestedField({
308
344
  object: rest,
309
345
  modelName,
310
346
  modelMap,
347
+ csiModelMap,
311
348
  createDocument
312
349
  });
313
350
  return {
@@ -319,22 +356,28 @@ async function createNestedField({
319
356
  newRefDocuments: [document, ...newRefDocuments]
320
357
  };
321
358
  }
322
- } 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
+ }
323
363
  if (!Array.isArray(value)) {
324
364
  throw new Error(`value for list field must be array`);
325
365
  }
326
366
  const itemsField = modelField.items;
327
- if (!itemsField) {
367
+ const csiItemsField = csiModelField.items;
368
+ if (!itemsField || !csiItemsField) {
328
369
  throw new Error(`list field does not define items`);
329
370
  }
330
371
  const arrayResult = await mapPromise(
331
372
  value,
332
373
  async (item, index): Promise<{ field: UpdateOperationListFieldItem; newRefDocuments: CSITypes.Document[] }> => {
333
- const result = await createNestedField({
374
+ const result = await createUpdateOperationFieldRecursively({
334
375
  value: item,
335
376
  modelField: itemsField,
377
+ csiModelField: csiItemsField,
336
378
  fieldPath: fieldPath.concat(index),
337
379
  modelMap,
380
+ csiModelMap,
338
381
  createDocument
339
382
  });
340
383
  if (result.field.type === 'list') {
@@ -353,10 +396,22 @@ async function createNestedField({
353
396
  },
354
397
  newRefDocuments: arrayResult.reduce((result: CSITypes.Document[], { newRefDocuments }) => result.concat(newRefDocuments), [])
355
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
+ };
356
411
  }
357
412
  return {
358
413
  field: {
359
- type: modelField.type,
414
+ type: csiModelField.type,
360
415
  value: value
361
416
  },
362
417
  newRefDocuments: []
@@ -367,24 +422,31 @@ export async function convertOperationField({
367
422
  operationField,
368
423
  fieldPath,
369
424
  modelField,
425
+ csiModelField,
370
426
  modelMap,
427
+ csiModelMap,
371
428
  createDocument
372
429
  }: {
373
430
  operationField: ContentStoreTypes.UpdateOperationField;
374
431
  fieldPath: (string | number)[];
375
- modelField: Field;
376
- modelMap: Record<string, Model>;
432
+ modelField: FieldSpecificProps;
433
+ csiModelField: FieldSpecificProps;
434
+ modelMap: Record<string, SDKModel>;
435
+ csiModelMap: Record<string, SDKModel>;
377
436
  createDocument: CreateDocumentCallback;
378
437
  }): Promise<CSITypes.UpdateOperationField> {
379
- // for insert operations, the modelField will be of the list, so get the modelField of the list items
380
- const modelFieldOrListItems: FieldSpecificProps = modelField.type === 'list' ? modelField.items! : modelField;
381
438
  switch (operationField.type) {
382
439
  case 'object': {
383
- 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({
384
444
  object: operationField.object,
385
- modelFields: (modelFieldOrListItems as FieldObjectProps).fields,
386
- fieldPath: fieldPath,
445
+ modelFields: modelField.fields,
446
+ csiModelFields: csiModelField.fields,
447
+ fieldPath,
387
448
  modelMap,
449
+ csiModelMap,
388
450
  createDocument
389
451
  });
390
452
  return {
@@ -394,14 +456,17 @@ export async function convertOperationField({
394
456
  }
395
457
  case 'model': {
396
458
  const model = modelMap[operationField.modelName];
397
- if (!model) {
459
+ const csiModel = csiModelMap[operationField.modelName];
460
+ if (!model || !csiModel) {
398
461
  throw new Error(`error updating document, could not find document model: '${operationField.modelName}'`);
399
462
  }
400
- const result = await createNestedObjectRecursively({
463
+ const result = await createObjectRecursively({
401
464
  object: operationField.object,
402
- modelFields: model.fields!,
465
+ modelFields: model.fields ?? [],
466
+ csiModelFields: csiModel.fields ?? [],
403
467
  fieldPath,
404
468
  modelMap,
469
+ csiModelMap,
405
470
  createDocument
406
471
  });
407
472
  return {
@@ -410,18 +475,22 @@ export async function convertOperationField({
410
475
  fields: result.fields
411
476
  };
412
477
  }
478
+ case 'reference':
479
+ return operationField;
413
480
  case 'list': {
414
- if (modelField.type !== 'list') {
415
- 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}'`);
416
483
  }
417
484
  const result: UpdateOperationListFieldItem[] = await mapPromise(
418
485
  operationField.items,
419
- async (item): Promise<UpdateOperationListFieldItem> => {
420
- const result = await createNestedField({
486
+ async (item, index): Promise<UpdateOperationListFieldItem> => {
487
+ const result = await createUpdateOperationFieldRecursively({
421
488
  value: item,
422
- modelField: modelField.items!,
423
- fieldPath,
489
+ modelField: modelField.items,
490
+ csiModelField: csiModelField.items,
491
+ fieldPath: fieldPath.concat(index),
424
492
  modelMap,
493
+ csiModelMap,
425
494
  createDocument
426
495
  });
427
496
  if (result.field.type === 'list') {
@@ -435,34 +504,48 @@ export async function convertOperationField({
435
504
  items: result
436
505
  };
437
506
  }
438
- case 'string':
439
- // When inserting new string value into a list, the client does not
440
- // send value. Set an empty string value.
441
- if (typeof operationField.value !== 'string') {
442
- return {
443
- type: operationField.type,
444
- value: ''
445
- };
446
- }
447
- return operationField as CSITypes.UpdateOperationField;
448
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
+ }
449
511
  // When inserting new enum value into a list, the client does not
450
512
  // send value. Set first option as the value.
451
513
  if (typeof operationField.value !== 'string') {
452
- if (modelFieldOrListItems.type !== 'enum') {
453
- 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}'`);
454
516
  }
455
- const option = modelFieldOrListItems.options[0]!;
517
+ const option = modelField.options[0]!;
456
518
  const optionValue = typeof option === 'object' ? option.value : option;
457
519
  return {
458
- type: operationField.type,
520
+ type: csiModelField.type,
459
521
  value: optionValue
460
522
  };
461
523
  }
462
- return operationField as CSITypes.UpdateOperationField;
463
- case 'image':
464
- return operationField as CSITypes.UpdateOperationField;
465
- default:
466
- 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
+ }
467
550
  }
468
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) {
@@ -184,10 +184,18 @@ function sanitizeAndGroupSiteMapEntries(siteMapEntries: SiteMapEntry[]): SiteMap
184
184
  }, {});
185
185
  }
186
186
 
187
- function getSiteMapGroupKey(siteMapEntry: SiteMapEntry) {
187
+ function getSiteMapGroupKey(siteMapEntry: SiteMapEntry): string {
188
188
  return 'document' in siteMapEntry
189
- ? `${siteMapEntry.document.srcType}:${siteMapEntry.document.srcProjectId}:${siteMapEntry.document.id}`
190
- : SiteMapStaticEntriesKey;
189
+ ? getSiteMapGroupKeyForDocument({
190
+ srcType: siteMapEntry.document.srcType,
191
+ srcProjectId: siteMapEntry.document.srcProjectId,
192
+ srcDocumentId: siteMapEntry.document.id
193
+ })
194
+ : SiteMapStaticEntriesKey.toString();
195
+ }
196
+
197
+ export function getSiteMapGroupKeyForDocument({ srcType, srcProjectId, srcDocumentId }: { srcType: string; srcProjectId: string; srcDocumentId: string }): string {
198
+ return `${srcType}:${srcProjectId}:${srcDocumentId}`;
191
199
  }
192
200
 
193
201
  export function getDocumentFieldLabelValueForSiteMapEntry({