@stackbit/cms-core 0.1.14-alpha.0 → 0.1.14-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -192,36 +192,3 @@ export function getCSIDocumentsAndAssetsFromContentSourceDataByIds(
192
192
  assets
193
193
  };
194
194
  }
195
-
196
- export function getDocumentFieldLabelValueForSiteMapEntry({
197
- siteMapEntry,
198
- locale,
199
- contentSourceDataById
200
- }: {
201
- siteMapEntry: SiteMapEntry;
202
- locale?: string;
203
- contentSourceDataById: Record<string, ContentStoreTypes.ContentSourceData>;
204
- }): string | null {
205
- if (!('document' in siteMapEntry)) {
206
- return null;
207
- }
208
- const contentSourceId = getContentSourceId(siteMapEntry.document.srcType, siteMapEntry.document.srcProjectId);
209
- const contentSourceData = contentSourceDataById[contentSourceId];
210
- if (!contentSourceData) {
211
- return null;
212
- }
213
- const labelFieldName = contentSourceData.modelMap[siteMapEntry.document.modelName]?.labelField;
214
- const document = contentSourceData.documentMap[siteMapEntry.document.id];
215
- if (!labelFieldName || !document) {
216
- return null;
217
- }
218
- const labelField = document.fields[labelFieldName];
219
- if (!labelField) {
220
- return null;
221
- }
222
- const localizedLabelField = getDocumentFieldForLocale(labelField, locale);
223
- if (!localizedLabelField || !('value' in localizedLabelField) || !localizedLabelField.value) {
224
- return null;
225
- }
226
- return String(localizedLabelField.value);
227
- }
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import sanitizeFilename from 'sanitize-filename';
4
4
 
5
5
  import * as CSITypes from '@stackbit/types';
6
- import { DocumentWithSource, getLocalizedFieldForLocale, ModelExtension, ModelWithSource, UserCommandSpawner } from '@stackbit/types';
6
+ import { getLocalizedFieldForLocale, ModelExtension, UserCommandSpawner } from '@stackbit/types';
7
7
  import {
8
8
  Config,
9
9
  extendModelsWithPresetsIds,
@@ -18,29 +18,34 @@ import {
18
18
  Preset,
19
19
  PresetMap
20
20
  } from '@stackbit/sdk';
21
- import { deferWhileRunning, mapPromise, reducePromise } from '@stackbit/utils';
21
+ import { append, deferWhileRunning, mapPromise, reducePromise } from '@stackbit/utils';
22
22
 
23
23
  import * as ContentStoreTypes from './content-store-types';
24
24
  import { Timer } from './utils/timer';
25
25
  import { SearchFilter } from './types/search-filter';
26
26
  import { searchDocuments } from './utils/search-utils';
27
27
  import { mapCSIAssetsToStoreAssets, mapCSIDocumentsToStoreDocuments } from './utils/csi-to-store-docs-converter';
28
- import { mapStoreDocumentsToCSIDocumentsWithSource } from './utils/store-to-csi-docs-converter';
29
28
  import {
30
29
  getContentSourceId,
31
30
  getContentSourceIdForContentSource,
32
- getDocumentFieldLabelValueForSiteMapEntry,
33
31
  getCSIDocumentsAndAssetsFromContentSourceDataByIds,
34
32
  getModelFieldForFieldAtPath,
35
33
  getUserContextForSrcType,
36
34
  groupDocumentsByContentSource,
37
35
  groupModelsByContentSource
38
36
  } from './content-store-utils';
37
+ import {
38
+ getSiteMapEntriesFromStackbitConfig,
39
+ updateSiteMapEntriesWithContentChanges,
40
+ getDocumentFieldLabelValueForSiteMapEntry,
41
+ SiteMapEntryGroups
42
+ } from './utils/site-map';
39
43
  import { mapAssetsToLocalizedApiImages, mapDocumentsToLocalizedApiObjects, mapStoreAssetsToAPIAssets } from './utils/store-to-api-docs-converter';
40
44
  import { convertOperationField, createDocumentRecursively, getCreateDocumentThunk } from './utils/create-update-csi-docs';
41
45
  import { mergeObjectWithDocument } from './utils/duplicate-document';
42
46
  import { normalizeModels, validateModels } from './utils/model-utils';
43
47
  import { IMAGE_MODEL } from './common/common-schema';
48
+ import { getDocumentObjectFromPreset, getPresetFromDocument } from './utils/preset-utils';
44
49
 
45
50
  export type HandleConfigAssets = <T extends Model>({ models, presets }: { models?: T[]; presets?: PresetMap }) => Promise<{ models: T[]; presets: PresetMap }>;
46
51
 
@@ -59,6 +64,8 @@ export interface ContentSourceOptions {
59
64
  type ContentSourceData = ContentStoreTypes.ContentSourceData;
60
65
  type ContentSourceRawData = Omit<ContentSourceData, 'models' | 'modelMap' | 'documents' | 'documentMap'>;
61
66
 
67
+ export const StackbitPresetModelName = 'stackbitPreset';
68
+
62
69
  export class ContentStore {
63
70
  private readonly logger: ContentStoreTypes.Logger;
64
71
  private readonly userLogger: ContentStoreTypes.Logger;
@@ -71,12 +78,14 @@ export class ContentStore {
71
78
  private readonly devAppRestartNeeded?: () => void;
72
79
  private contentSources: CSITypes.ContentSourceInterface[] = [];
73
80
  private contentSourceDataById: Record<string, ContentSourceData> = {};
81
+ private presetsContentSource?: CSITypes.ContentSourceInterface;
74
82
  private contentUpdatesWatchTimer: Timer;
75
83
  private stackbitConfig: Config | null = null;
76
84
  private yamlModels: Model[] = [];
77
85
  private configModels: Model[] = [];
78
86
  private modelExtensions: ModelExtension[] | null = null;
79
87
  private presets: PresetMap = {};
88
+ private siteMapEntryGroups: SiteMapEntryGroups = {};
80
89
 
81
90
  constructor(options: ContentSourceOptions) {
82
91
  this.logger = options.logger.createLogger({ label: 'content-store' });
@@ -133,7 +142,6 @@ export class ContentStore {
133
142
  this.yamlModels = await this.loadYamlModels({ stackbitConfig });
134
143
  this.configModels = this.mergeConfigModels(stackbitConfig.models ?? [], this.yamlModels);
135
144
  }
136
- this.presets = await this.loadPresets({ stackbitConfig });
137
145
  }
138
146
 
139
147
  await this.loadContentSourcesAndProcessData({ init: true });
@@ -211,10 +219,10 @@ export class ContentStore {
211
219
  // Check if any of the preset files were changed. If presets were changed, reload them.
212
220
  const presetDirs = getPresetDirs(this.stackbitConfig);
213
221
  const presetsChanged = updatedFiles.find((updatedFile) => _.some(presetDirs, (presetDir) => updatedFile.startsWith(presetDir)));
214
- if (presetsChanged) {
222
+ if (presetsChanged && !this.usesContentSourcePresets()) {
215
223
  this.logger.debug('identified change in stackbit preset files');
216
224
  schemaChanged = true;
217
- this.presets = await this.loadPresets({ stackbitConfig: this.stackbitConfig });
225
+ this.presets = await this.loadPresetsFromConfig({ stackbitConfig: this.stackbitConfig });
218
226
  }
219
227
  }
220
228
 
@@ -237,7 +245,7 @@ export class ContentStore {
237
245
  }
238
246
  }
239
247
 
240
- const contentChanges: ContentStoreTypes.ContentChangeResult = {
248
+ let contentChanges: ContentStoreTypes.ContentChangeResult = {
241
249
  updatedDocuments: [],
242
250
  updatedAssets: [],
243
251
  deletedDocuments: [],
@@ -250,14 +258,22 @@ export class ContentStore {
250
258
  if (schemaChanged) {
251
259
  await this.loadContentSourcesAndProcessData({ init: false, contentSourceIds: contentSourceIdsWithChangedSchema });
252
260
  } else {
253
- contentChangeEvents.reduce((contentChanges, { contentSourceId, contentChangeEvent }) => {
261
+ contentChanges = contentChangeEvents.reduce((contentChanges, { contentSourceId, contentChangeEvent }) => {
254
262
  const contentChangeResult = this.onContentChange(contentSourceId, contentChangeEvent);
255
- contentChanges.updatedDocuments = contentChanges.updatedDocuments.concat(contentChangeResult.updatedDocuments);
256
- contentChanges.updatedAssets = contentChanges.updatedAssets.concat(contentChangeResult.updatedAssets);
257
- contentChanges.deletedDocuments = contentChanges.deletedDocuments.concat(contentChangeResult.deletedDocuments);
258
- contentChanges.deletedAssets = contentChanges.deletedAssets.concat(contentChangeResult.deletedAssets);
259
- return contentChanges;
263
+ return {
264
+ updatedDocuments: contentChanges.updatedDocuments.concat(contentChangeResult.updatedDocuments),
265
+ updatedAssets: contentChanges.updatedAssets.concat(contentChangeResult.updatedAssets),
266
+ deletedDocuments: contentChanges.deletedDocuments.concat(contentChangeResult.deletedDocuments),
267
+ deletedAssets: contentChanges.deletedAssets.concat(contentChangeResult.deletedAssets)
268
+ };
260
269
  }, contentChanges);
270
+
271
+ this.siteMapEntryGroups = await updateSiteMapEntriesWithContentChanges({
272
+ siteMapEntryGroups: this.siteMapEntryGroups,
273
+ contentChanges,
274
+ stackbitConfig: this.stackbitConfig,
275
+ contentSourceDataById: this.contentSourceDataById
276
+ });
261
277
  }
262
278
 
263
279
  // TODO: maybe instead of returning object with results
@@ -284,7 +300,7 @@ export class ContentStore {
284
300
  return configModelsResult.models;
285
301
  }
286
302
 
287
- private async loadPresets({ stackbitConfig }: { stackbitConfig: Config }): Promise<Record<string, Preset>> {
303
+ private async loadPresetsFromConfig({ stackbitConfig }: { stackbitConfig: Config }): Promise<PresetMap> {
288
304
  const contentSources = stackbitConfig?.contentSources ?? [];
289
305
  const singleContentSource = contentSources.length === 1 ? contentSources[0] : null;
290
306
  const presetResult = await loadPresets({
@@ -303,6 +319,29 @@ export class ContentStore {
303
319
  return presets;
304
320
  }
305
321
 
322
+ private async loadPresetsFromContentSource(contentSourceData: ContentSourceRawData): Promise<PresetMap> {
323
+ const presets = _.reduce(
324
+ contentSourceData.csiDocuments,
325
+ (result: Record<string, Preset>, csiDocument) => {
326
+ if (csiDocument.modelName === StackbitPresetModelName) {
327
+ const preset = getPresetFromDocument({
328
+ srcType: contentSourceData.srcType,
329
+ srcProjectId: contentSourceData.srcProjectId,
330
+ csiDocument,
331
+ csiAssetMap: contentSourceData.csiAssetMap,
332
+ logger: this.logger
333
+ });
334
+ if (preset) {
335
+ result[csiDocument.id] = preset;
336
+ }
337
+ }
338
+ return result;
339
+ },
340
+ {}
341
+ );
342
+ return presets;
343
+ }
344
+
306
345
  /**
307
346
  * This function reloads the data of the specified content-sources, while
308
347
  * reusing the cached data of the rest of the content-sources, then processes
@@ -338,6 +377,27 @@ export class ContentStore {
338
377
 
339
378
  const contentSourceRawDataArr = await Promise.all(promises);
340
379
 
380
+ // find first content source that supports presets
381
+ for (let i = 0; i < contentSources.length; i++) {
382
+ const contentSourceDataRaw = contentSourceRawDataArr[i];
383
+ if (contentSourceDataRaw?.csiModelMap?.[StackbitPresetModelName]) {
384
+ this.presetsContentSource = contentSources[i];
385
+ if (this.presetsContentSource) {
386
+ const contentSourceId = getContentSourceIdForContentSource(this.presetsContentSource);
387
+ // reload presets from content source only if needed
388
+ if (init || !contentSourceIds || contentSourceIds.includes(contentSourceId)) {
389
+ this.presets = await this.loadPresetsFromContentSource(contentSourceDataRaw);
390
+ }
391
+ }
392
+ break;
393
+ }
394
+ }
395
+
396
+ // fallback to loading presets from config as usual
397
+ if (init && this.stackbitConfig && !this.presetsContentSource) {
398
+ this.presets = await this.loadPresetsFromConfig({ stackbitConfig: this.stackbitConfig });
399
+ }
400
+
341
401
  // update all content sources at once to prevent race conditions
342
402
  this.contentSourceDataById = await this.processData({
343
403
  stackbitConfig: this.stackbitConfig,
@@ -346,6 +406,12 @@ export class ContentStore {
346
406
  contentSourceRawDataArr: contentSourceRawDataArr
347
407
  });
348
408
  this.contentSources = contentSources;
409
+
410
+ // generate create site map entries
411
+ this.siteMapEntryGroups = await getSiteMapEntriesFromStackbitConfig({
412
+ stackbitConfig: this.stackbitConfig,
413
+ contentSourceDataById: this.contentSourceDataById
414
+ });
349
415
  }
350
416
 
351
417
  private async loadContentSourceData({
@@ -409,9 +475,15 @@ export class ContentStore {
409
475
  getAsset({ assetId }: { assetId: string }) {
410
476
  return csiAssetMap[assetId];
411
477
  },
412
- onContentChange: (contentChangeEvent: CSITypes.ContentChangeEvent) => {
478
+ onContentChange: async (contentChangeEvent: CSITypes.ContentChangeEvent) => {
413
479
  this.logger.debug('content source called onContentChange', { contentSourceId });
414
480
  const result = this.onContentChange(contentSourceId, contentChangeEvent);
481
+ this.siteMapEntryGroups = await updateSiteMapEntriesWithContentChanges({
482
+ siteMapEntryGroups: this.siteMapEntryGroups,
483
+ contentChanges: result,
484
+ stackbitConfig: this.stackbitConfig,
485
+ contentSourceDataById: this.contentSourceDataById
486
+ });
415
487
  this.onContentChangeCallback(result);
416
488
  },
417
489
  onSchemaChange: async () => {
@@ -442,6 +514,9 @@ export class ContentStore {
442
514
  private onContentChange(contentSourceId: string, contentChangeEvent: CSITypes.ContentChangeEvent): ContentStoreTypes.ContentChangeResult {
443
515
  // TODO: prevent content change process for contentSourceId if loading content is in progress
444
516
 
517
+ // certain content changes, like preset changes are interpreted as schema changes
518
+ let schemaChanged = false;
519
+
445
520
  this.logger.debug('onContentChange', {
446
521
  contentSourceId,
447
522
  documentCount: contentChangeEvent.documents.length,
@@ -461,6 +536,19 @@ export class ContentStore {
461
536
 
462
537
  // update contentSourceData with deleted documents
463
538
  contentChangeEvent.deletedDocumentIds.forEach((docId) => {
539
+ // remove preset, make sure there is something to remove first because
540
+ // were explicitly calling onContentChange from deletePreset as well
541
+ if (this.presets[docId] && contentSourceData.csiDocumentMap[docId]?.modelName === StackbitPresetModelName) {
542
+ schemaChanged = true;
543
+ const preset = this.presets[docId]!;
544
+ const model = contentSourceData.modelMap[preset.modelName];
545
+ delete this.presets[docId];
546
+ if (model && model.presets) {
547
+ const presetIdIndex = model.presets.findIndex((presetId) => presetId === docId);
548
+ model.presets.splice(presetIdIndex, 1);
549
+ }
550
+ }
551
+
464
552
  // delete document from documents map
465
553
  delete contentSourceData.documentMap[docId];
466
554
  delete contentSourceData.csiDocumentMap[docId];
@@ -557,10 +645,26 @@ export class ContentStore {
557
645
  contentSourceData.documents.push(document);
558
646
  contentSourceData.csiDocuments.push(csiDocument);
559
647
  } else {
560
- // the indexes of documents and csiDocuments are always the same as they are always updated at the same time
561
648
  contentSourceData.documents.splice(dataIndex, 1, document);
562
649
  contentSourceData.csiDocuments.splice(dataIndex, 1, csiDocument);
563
650
  }
651
+ if (csiDocument.modelName === StackbitPresetModelName) {
652
+ schemaChanged = true;
653
+ const preset = getPresetFromDocument({
654
+ srcType: contentSourceData.srcType,
655
+ srcProjectId: contentSourceData.srcProjectId,
656
+ csiDocument,
657
+ csiAssetMap: contentSourceData.csiAssetMap,
658
+ logger: this.logger
659
+ });
660
+ if (preset) {
661
+ this.presets[csiDocument.id] = preset;
662
+ if (dataIndex === -1) {
663
+ //TODO recalculate assets as well
664
+ contentSourceData.modelMap[preset.modelName]?.presets?.push(csiDocument.id);
665
+ }
666
+ }
667
+ }
564
668
  result.updatedDocuments.push({
565
669
  srcType: contentSourceData.srcType,
566
670
  srcProjectId: contentSourceData.srcProjectId,
@@ -588,6 +692,10 @@ export class ContentStore {
588
692
  });
589
693
  }
590
694
 
695
+ if (schemaChanged) {
696
+ this.onSchemaChangeCallback?.();
697
+ }
698
+
591
699
  return result;
592
700
  }
593
701
 
@@ -623,48 +731,58 @@ export class ContentStore {
623
731
  // srcProjectId. If after the comparison, there are more than one model left,
624
732
  // log a warning and filter out that config model so it won't be merged with any
625
733
  // of the content source models.
626
- const modelMatchErrors: { configModel: ModelExtension; matchedCSIModels: CSITypes.ModelWithSource[] }[] = [];
627
- const filteredConfigModels = (configModels as ModelExtension[]).filter((configModel) => {
734
+ const nonMatchedModels: { configModel: ModelExtension; matchedCSIModels: CSITypes.ModelWithSource[] }[] = [];
735
+ const configModelsByContentSourceId = (configModels as ModelExtension[]).reduce((modelGroups: Record<string, Model[]>, configModel) => {
628
736
  const csiModels = csiModelGroups[configModel.name];
629
737
  if (!csiModels) {
630
- return false;
738
+ nonMatchedModels.push({
739
+ configModel,
740
+ matchedCSIModels: []
741
+ });
742
+ return modelGroups;
631
743
  }
632
744
  const matchedCSIModels = csiModels.filter((model) => {
633
745
  const matchesType = !configModel.srcType || model.srcType === configModel.srcType;
634
746
  const matchesId = !configModel.srcProjectId || model.srcProjectId === configModel.srcProjectId;
635
747
  return matchesType && matchesId;
636
748
  });
637
- if (matchedCSIModels.length === 0) {
638
- return false;
639
- }
640
- if (matchedCSIModels.length === 1) {
641
- return true;
749
+ if (matchedCSIModels.length !== 1) {
750
+ nonMatchedModels.push({
751
+ configModel,
752
+ matchedCSIModels
753
+ });
754
+ return modelGroups;
642
755
  }
643
- modelMatchErrors.push({
644
- configModel,
645
- matchedCSIModels
646
- });
647
- return false;
648
- }) as Model[];
756
+ const contentSource = matchedCSIModels[0]!;
757
+ const contentSourceId = getContentSourceId(contentSource.srcType, contentSource.srcProjectId);
758
+ append(modelGroups, contentSourceId, configModel);
759
+ return modelGroups;
760
+ }, {});
649
761
 
650
762
  // Log model matching warnings using user logger
651
- for (const { configModel, matchedCSIModels } of modelMatchErrors) {
652
- let message = `name: '${configModel.name}'`;
763
+ for (const { configModel, matchedCSIModels } of nonMatchedModels) {
764
+ let configModelMessage = `model name: '${configModel.name}'`;
653
765
  if (configModel.srcType) {
654
- message += `, srcType: '${configModel.srcType}'`;
766
+ configModelMessage += `, srcType: '${configModel.srcType}'`;
655
767
  }
656
768
  if (configModel.srcProjectId) {
657
- message += `, srcProjectId: '${configModel.srcProjectId}'`;
769
+ configModelMessage += `, srcProjectId: '${configModel.srcProjectId}'`;
658
770
  }
659
- const matchesModelsMessage = matchedCSIModels.map((model) => `srcType: '${model.srcType}', srcProjectId: '${model.srcProjectId}'`).join('; ');
660
- this.userLogger.warn(
661
- `model ${message} defined in stackbit config matches more that 1 model in the following content sources: ${matchesModelsMessage}`
662
- );
771
+ configModelMessage = configModelMessage + ` defined in stackbit config`;
772
+ let contentSourceModelsMessage;
773
+ if (matchedCSIModels.length) {
774
+ const matchesModelsMessage = matchedCSIModels.map((model) => `srcType: '${model.srcType}', srcProjectId: '${model.srcProjectId}'`).join('; ');
775
+ contentSourceModelsMessage = ` matches more that 1 model in the following content sources: ${matchesModelsMessage}`;
776
+ } else {
777
+ contentSourceModelsMessage = ' does not match any content source model';
778
+ }
779
+ this.userLogger.warn(configModelMessage + contentSourceModelsMessage);
663
780
  }
664
781
 
665
782
  const modelsWithSource = contentSourceRawDataArr.reduce((accum: CSITypes.ModelWithSource[], csData) => {
783
+ const contentSourceId = getContentSourceId(csData.srcType, csData.srcProjectId);
666
784
  const mergedModels = mergeConfigModelsWithExternalModels({
667
- configModels: filteredConfigModels,
785
+ configModels: configModelsByContentSourceId[contentSourceId] ?? [],
668
786
  externalModels: csData.csiModels
669
787
  });
670
788
  const modelsWithSource = mergedModels.map(
@@ -687,34 +805,39 @@ export class ContentStore {
687
805
  const modelsWithPresetsIds = extendModelsWithPresetsIds({ models: validatedModels, presets });
688
806
  const { models } = await this.handleConfigAssets({ models: modelsWithPresetsIds });
689
807
 
690
- const csiDocumentsWithSource = contentSourceRawDataArr.reduce((accum: CSITypes.DocumentWithSource[], csData) => {
691
- const csiDocumentsWithSource = csData.csiDocuments.map(
692
- (csiDocument): CSITypes.DocumentWithSource => ({
693
- srcType: csData.srcType,
694
- srcProjectId: csData.srcProjectId,
695
- ...csiDocument
696
- })
697
- );
698
- return accum.concat(csiDocumentsWithSource);
699
- }, []);
700
-
701
- // TODO: Is there a better way than deep cloning objects before passing them to user methods?
702
- // Not cloning mutable objects will break the internal state if user mutates the objects.
703
- const mappedDocs =
704
- stackbitConfig?.mapDocuments?.({
705
- documents: _.cloneDeep(csiDocumentsWithSource),
706
- models: _.cloneDeep(models)
707
- }) ?? csiDocumentsWithSource;
808
+ let documentMapByContentSource: Record<string, Record<string, CSITypes.Document[]>> | null = null;
809
+ if (stackbitConfig?.mapDocuments) {
810
+ const csiDocumentsWithSource = contentSourceRawDataArr.reduce((accum: CSITypes.DocumentWithSource[], csData) => {
811
+ const csiDocumentsWithSource = csData.csiDocuments.map(
812
+ (csiDocument): CSITypes.DocumentWithSource => ({
813
+ srcType: csData.srcType,
814
+ srcProjectId: csData.srcProjectId,
815
+ ...csiDocument
816
+ })
817
+ );
818
+ return accum.concat(csiDocumentsWithSource);
819
+ }, []);
820
+
821
+ // TODO: Is there a better way than deep cloning objects before passing them to user methods?
822
+ // Not cloning mutable objects will break the internal state if user mutates the objects.
823
+ const mappedDocs =
824
+ stackbitConfig?.mapDocuments?.({
825
+ documents: _.cloneDeep(csiDocumentsWithSource),
826
+ models: _.cloneDeep(models)
827
+ }) ?? csiDocumentsWithSource;
828
+ documentMapByContentSource = groupDocumentsByContentSource({ documents: mappedDocs });
829
+ }
708
830
 
709
831
  const modelMapByContentSource = groupModelsByContentSource({ models: models });
710
- const documentMapByContentSource = groupDocumentsByContentSource({ documents: mappedDocs });
711
832
 
712
833
  const contentSourceDataArr = contentSourceRawDataArr.map(
713
834
  (csData): ContentSourceData => {
714
835
  const modelMap = _.get(modelMapByContentSource, [csData.srcType, csData.srcProjectId], {});
715
- const mappedCSIDocuments = _.get(documentMapByContentSource, [csData.srcType, csData.srcProjectId], []);
836
+ const csiDocuments = documentMapByContentSource
837
+ ? _.get(documentMapByContentSource, [csData.srcType, csData.srcProjectId], [])
838
+ : csData.csiDocuments;
716
839
  const documents = mapCSIDocumentsToStoreDocuments({
717
- csiDocuments: mappedCSIDocuments,
840
+ csiDocuments: csiDocuments,
718
841
  contentSourceInstance: csData.instance,
719
842
  defaultLocaleCode: csData.defaultLocaleCode,
720
843
  modelMap: modelMap
@@ -770,6 +893,10 @@ export class ContentStore {
770
893
  return contentSourceData.instance.getProjectEnvironment();
771
894
  }
772
895
 
896
+ usesContentSourcePresets() {
897
+ return Boolean(this.presetsContentSource);
898
+ }
899
+
773
900
  async hasAccess({
774
901
  srcType,
775
902
  srcProjectId,
@@ -878,64 +1005,32 @@ export class ContentStore {
878
1005
  }
879
1006
 
880
1007
  getSiteMapEntries({ locale }: { locale?: string } = {}): CSITypes.SiteMapEntry[] {
881
- if (!this.stackbitConfig?.siteMap) {
882
- return [];
883
- }
884
-
885
- // TODO: cache siteMap in processData
886
- const siteMapOptions = _.reduce(
887
- this.contentSourceDataById,
888
- (accum: { models: ModelWithSource[]; documents: DocumentWithSource[] }, contentSourceData) => {
889
- return {
890
- models: accum.models.concat(
891
- contentSourceData.models.map((model) => ({
892
- srcType: contentSourceData.srcType,
893
- srcProjectId: contentSourceData.srcProjectId,
894
- ...model
895
- }))
896
- ),
897
- documents: accum.documents.concat(mapStoreDocumentsToCSIDocumentsWithSource(contentSourceData.documents))
898
- };
1008
+ const siteMapEntries = _.reduce(
1009
+ this.siteMapEntryGroups,
1010
+ (accum: CSITypes.SiteMapEntry[], siteMapEntryGroup) => {
1011
+ return _.reduce(
1012
+ siteMapEntryGroup,
1013
+ (accum: CSITypes.SiteMapEntry[], siteMapEntry) => {
1014
+ if (!siteMapEntry.label) {
1015
+ const fieldLabelValue = getDocumentFieldLabelValueForSiteMapEntry({
1016
+ siteMapEntry,
1017
+ locale,
1018
+ contentSourceDataById: this.contentSourceDataById
1019
+ });
1020
+ siteMapEntry = {
1021
+ ...siteMapEntry,
1022
+ label: fieldLabelValue ?? siteMapEntry.urlPath
1023
+ };
1024
+ }
1025
+ accum.push(siteMapEntry);
1026
+ return accum;
1027
+ },
1028
+ accum
1029
+ );
899
1030
  },
900
- { models: [], documents: [] }
1031
+ []
901
1032
  );
902
1033
 
903
- const siteMapEntries = this.stackbitConfig.siteMap(siteMapOptions).reduce((accum: CSITypes.SiteMapEntry[], siteMapEntry) => {
904
- // The site map entries are provided by user, sanitize them and filter out illegal entries
905
- if (!siteMapEntry) {
906
- return accum;
907
- }
908
-
909
- if (typeof siteMapEntry.urlPath !== 'string') {
910
- return accum;
911
- }
912
-
913
- if ('document' in siteMapEntry) {
914
- const doc = siteMapEntry.document;
915
- if (!doc.srcType || !doc.srcProjectId || !doc.modelName || !doc.id) {
916
- return accum;
917
- }
918
- }
919
-
920
- if (!siteMapEntry.label) {
921
- const fieldLabelValue = getDocumentFieldLabelValueForSiteMapEntry({ siteMapEntry, locale, contentSourceDataById: this.contentSourceDataById });
922
- siteMapEntry = {
923
- ...siteMapEntry,
924
- label: fieldLabelValue ?? siteMapEntry.urlPath
925
- };
926
- }
927
-
928
- if (!siteMapEntry.stableId) {
929
- siteMapEntry = {
930
- ...siteMapEntry,
931
- stableId: 'document' in siteMapEntry ? siteMapEntry.document.id : siteMapEntry.urlPath
932
- };
933
- }
934
-
935
- accum.push(siteMapEntry);
936
- return accum;
937
- }, []);
938
-
939
1034
  return _.isEmpty(locale) ? siteMapEntries : siteMapEntries.filter((siteMapEntry) => !siteMapEntry.locale || siteMapEntry.locale === locale);
940
1035
  }
941
1036
 
@@ -960,7 +1055,8 @@ export class ContentStore {
960
1055
  const currentDocuments = _.isEmpty(locale)
961
1056
  ? contentSourceData.documents
962
1057
  : contentSourceData.documents.filter((document) => !document.locale || document.locale === locale);
963
- return documents.concat(currentDocuments);
1058
+ const filteredDocuments = currentDocuments.filter((document) => document.srcModelName !== StackbitPresetModelName);
1059
+ return documents.concat(filteredDocuments);
964
1060
  },
965
1061
  []
966
1062
  );
@@ -997,7 +1093,8 @@ export class ContentStore {
997
1093
  ? contentSourceData.assets.filter((asset) => !asset.locale || asset.locale === locale)
998
1094
  : contentSourceData.assets;
999
1095
  const currentLocale = locale ?? contentSourceData.defaultLocaleCode;
1000
- const documentObjects = mapDocumentsToLocalizedApiObjects(documents, currentLocale);
1096
+ const filteredDocuments = documents.filter((document) => document.srcModelName !== StackbitPresetModelName);
1097
+ const documentObjects = mapDocumentsToLocalizedApiObjects(filteredDocuments, currentLocale);
1001
1098
  const imageObjects = mapAssetsToLocalizedApiImages(assets, currentLocale);
1002
1099
  return objects.concat(documentObjects, imageObjects);
1003
1100
  },
@@ -1154,6 +1251,63 @@ export class ContentStore {
1154
1251
  return { srcDocumentId: updatedDocument.id };
1155
1252
  }
1156
1253
 
1254
+ async createPreset({
1255
+ preset,
1256
+ thumbnailAsset,
1257
+ user
1258
+ }: {
1259
+ preset: Preset;
1260
+ thumbnailAsset: ContentStoreTypes.UploadAssetData;
1261
+ user?: ContentStoreTypes.User;
1262
+ }): Promise<{ srcDocumentId: string }> {
1263
+ if (!this.presetsContentSource) {
1264
+ throw new Error('No content source available for preset saving');
1265
+ }
1266
+ let thumbnail: string | undefined;
1267
+ if (thumbnailAsset) {
1268
+ const assets = await this.uploadAssets({
1269
+ srcType: this.presetsContentSource.getContentSourceType(),
1270
+ srcProjectId: this.presetsContentSource.getProjectId(),
1271
+ assets: [thumbnailAsset],
1272
+ user
1273
+ });
1274
+ thumbnail = assets[0]?.objectId;
1275
+ }
1276
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(getContentSourceIdForContentSource(this.presetsContentSource));
1277
+ const document = await this.createDocument({
1278
+ srcType: this.presetsContentSource.getContentSourceType(),
1279
+ srcProjectId: this.presetsContentSource.getProjectId(),
1280
+ modelName: StackbitPresetModelName,
1281
+ object: {
1282
+ ...getDocumentObjectFromPreset(preset, contentSourceData.modelMap[StackbitPresetModelName]),
1283
+ thumbnail
1284
+ },
1285
+ user
1286
+ });
1287
+ return { srcDocumentId: document.srcDocumentId };
1288
+ }
1289
+
1290
+ async deletePreset({ presetId, user }: { presetId: string; user?: ContentStoreTypes.User }) {
1291
+ if (!this.presetsContentSource) {
1292
+ throw new Error('No content source available for preset deleting');
1293
+ }
1294
+ await this.deleteDocument({
1295
+ srcType: this.presetsContentSource.getContentSourceType(),
1296
+ srcProjectId: this.presetsContentSource.getProjectId(),
1297
+ srcDocumentId: presetId,
1298
+ user
1299
+ });
1300
+
1301
+ // we delete presets immediately because some CMSs don't notify us
1302
+ // when documents have been deleted.
1303
+ this.onContentChange(getContentSourceIdForContentSource(this.presetsContentSource), {
1304
+ documents: [],
1305
+ deletedDocumentIds: [presetId],
1306
+ assets: [],
1307
+ deletedAssetIds: []
1308
+ });
1309
+ }
1310
+
1157
1311
  async uploadAndLinkAsset({
1158
1312
  srcType,
1159
1313
  srcProjectId,
@@ -1608,7 +1762,9 @@ export class ContentStore {
1608
1762
  const contentSourceDocuments = _.isEmpty(locale)
1609
1763
  ? contentSourceData.documents
1610
1764
  : contentSourceData.documents.filter((document) => !document.locale || document.locale === locale);
1611
- documents.push(...contentSourceDocuments);
1765
+
1766
+ const filteredDocuments = contentSourceDocuments.filter((document) => document.srcModelName !== StackbitPresetModelName);
1767
+ documents.push(...filteredDocuments);
1612
1768
 
1613
1769
  if (contentSourceData.defaultLocaleCode) {
1614
1770
  defaultLocales[contentSourceId] = contentSourceData.defaultLocaleCode;