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

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.
@@ -0,0 +1,94 @@
1
+ import _ from 'lodash';
2
+ import { Preset } from '@stackbit/sdk';
3
+ import * as CSITypes from '@stackbit/types';
4
+ import { DocumentField, getLocalizedFieldForLocale, Logger, Model } from '@stackbit/types';
5
+
6
+ function getPresetData(presetId: string, field: DocumentField, logger?: Logger) {
7
+ const dataField = getLocalizedFieldForLocale(field);
8
+ let data;
9
+ if (dataField?.type === 'json') {
10
+ data = dataField.value;
11
+ } else if (dataField?.type === 'string' || dataField?.type === 'text') {
12
+ try {
13
+ data = JSON.parse(dataField.value);
14
+ } catch (err) {
15
+ logger?.error(`Can't parse data field for preset ${presetId}`);
16
+ }
17
+ }
18
+ return data;
19
+ }
20
+
21
+ function getPresetThumbnail(presetId: string, thumbnailField: DocumentField, csiAssetMap: Record<string, CSITypes.Asset>, logger?: Logger) {
22
+ let thumbnail;
23
+ if (thumbnailField?.type === 'image') {
24
+ const imageFields = getLocalizedFieldForLocale(thumbnailField)?.fields;
25
+ if (imageFields) {
26
+ const urlField = imageFields.url;
27
+ if (urlField?.localized) {
28
+ const vals = Object.values(urlField.locales);
29
+ if (vals?.length) {
30
+ thumbnail = vals[0]?.value;
31
+ }
32
+ } else {
33
+ thumbnail = urlField.value;
34
+ }
35
+ }
36
+ } else if (thumbnailField?.type === 'reference' && thumbnailField.refType === 'asset') {
37
+ const refId = getLocalizedFieldForLocale(thumbnailField)?.refId;
38
+ if (refId) {
39
+ const fileField = csiAssetMap[refId]?.fields.file;
40
+ if (fileField?.localized) {
41
+ const vals = Object.values(fileField.locales);
42
+ if (vals?.length) {
43
+ thumbnail = vals[0]?.url;
44
+ }
45
+ } else {
46
+ thumbnail = fileField?.url;
47
+ }
48
+ } else {
49
+ logger?.warn(`No thumbnail reference found for preset ${presetId}`);
50
+ }
51
+ }
52
+ return thumbnail;
53
+ }
54
+
55
+ export function getPresetFromDocument({
56
+ srcType,
57
+ srcProjectId,
58
+ csiDocument,
59
+ csiAssetMap,
60
+ logger
61
+ }: {
62
+ srcType: string;
63
+ srcProjectId: string;
64
+ csiDocument: CSITypes.Document;
65
+ csiAssetMap: Record<string, CSITypes.Asset>;
66
+ logger?: Logger;
67
+ }): Preset | null {
68
+ const data = csiDocument.fields['data'] ? getPresetData(csiDocument.id, csiDocument.fields['data'], logger) : null;
69
+ if (!data) {
70
+ logger?.warn(`Error finding preset data for preset ${csiDocument.id}`);
71
+ return null;
72
+ }
73
+ const thumbnail = csiDocument.fields['thumbnail'] ? getPresetThumbnail(csiDocument.id, csiDocument.fields['thumbnail'], csiAssetMap, logger) : null;
74
+ return {
75
+ srcType,
76
+ srcProjectId,
77
+ ...data,
78
+ thumbnail
79
+ };
80
+ }
81
+
82
+ export function getDocumentObjectFromPreset(preset: Preset, model?: Model): Record<string, any> {
83
+ let presetData: Record<string, any> | string = _.omit(preset, 'thumbnail');
84
+ if (model && model.fields) {
85
+ const dataField = model.fields.find((field) => field.name === 'data');
86
+ if (dataField?.type === 'string' || dataField?.type === 'text') {
87
+ presetData = JSON.stringify(presetData, null, 2);
88
+ }
89
+ }
90
+ return {
91
+ label: preset.label,
92
+ data: presetData
93
+ };
94
+ }
@@ -0,0 +1,224 @@
1
+ import _ from 'lodash';
2
+ import { Config } from '@stackbit/sdk';
3
+ import { append } from '@stackbit/utils';
4
+ import { SiteMapEntry } from '@stackbit/types';
5
+ import * as CSITypes from '@stackbit/types';
6
+ import * as ContentStoreTypes from '../content-store-types';
7
+ import { mapStoreDocumentsToCSIDocumentsWithSource } from './store-to-csi-docs-converter';
8
+ import { getContentSourceId, getDocumentFieldForLocale } from '../content-store-utils';
9
+
10
+ export const SiteMapStaticEntriesKey = Symbol.for('SiteMapStaticEntriesKey');
11
+ export type SiteMapEntriesSourceKeys = string | symbol;
12
+ /**
13
+ * SiteMapEntryGroups is a two level hashmap.
14
+ * If the SiteMapEntry is document-related, the first level key will be an
15
+ * identifier of the document including content source, and the second key will
16
+ * be the stableId. If the SiteMapEntry is static entry, the first key will
17
+ * be a constant Symbol and the second key will be the stableId.
18
+ * {
19
+ * `{srcType}:{srcProjectId}:{srcDocumentId}`: {
20
+ * `{stableId}`: SiteMapEntry
21
+ * },
22
+ * [SiteMapStaticEntriesKey]: {
23
+ * `{stableId}`: SiteMapEntry
24
+ * }
25
+ * }
26
+ */
27
+ export type SiteMapEntryGroups = Record<SiteMapEntriesSourceKeys, Record<string, SiteMapEntry>>;
28
+
29
+ export async function getSiteMapEntriesFromStackbitConfig({
30
+ stackbitConfig,
31
+ contentSourceDataById
32
+ }: {
33
+ stackbitConfig: Config | null;
34
+ contentSourceDataById: Record<string, ContentStoreTypes.ContentSourceData>;
35
+ }): Promise<SiteMapEntryGroups> {
36
+ if (!stackbitConfig?.siteMap) {
37
+ return {};
38
+ }
39
+
40
+ const siteMapOptions = _.reduce(
41
+ contentSourceDataById,
42
+ (accum: { models: CSITypes.ModelWithSource[]; documents: CSITypes.DocumentWithSource[] }, contentSourceData) => {
43
+ return {
44
+ models: accum.models.concat(
45
+ contentSourceData.models.map((model) => ({
46
+ srcType: contentSourceData.srcType,
47
+ srcProjectId: contentSourceData.srcProjectId,
48
+ ...model
49
+ }))
50
+ ),
51
+ documents: accum.documents.concat(mapStoreDocumentsToCSIDocumentsWithSource(contentSourceData.documents))
52
+ };
53
+ },
54
+ { models: [], documents: [] }
55
+ );
56
+
57
+ const rawSiteMapEntries = await stackbitConfig.siteMap(siteMapOptions);
58
+
59
+ // The rawSiteMapEntries entries are provided by the user, sanitize them and filter out illegal entries
60
+ return sanitizeAndGroupSiteMapEntries(rawSiteMapEntries);
61
+ }
62
+
63
+ /**
64
+ * Because the sitemap is directly affected by documents, the sitemap can change
65
+ * whenever there is a content change. For example, if a new document is added
66
+ * or deleted, a new sitemap entry would be added or deleted respectively.
67
+ * Likewise, if a slug of an existing document is changed, the sitemap entry for
68
+ * that document would also change.
69
+ *
70
+ * However, to improve overall performance, we don't want to call
71
+ * stackbitConfig.siteMap() with all the documents when a small set of documents
72
+ * is changed. Instead, we want to call stackbitConfig.siteMap() with only the
73
+ * changed documents. Then we merge the partial sitemap entries with the
74
+ * existing sitemap entries using sitemap entry identifiers such as
75
+ * srcDocumentId for document-related entries and stackbitId for static entries.
76
+ *
77
+ * @param siteMapEntries Existing sitemap entries
78
+ * @param contentChanges A ContentChangeResult including new, changed and
79
+ * deleted documents
80
+ * @param stackbitConfig Stackbit config
81
+ * @param contentSourceDataById ContentSourceData by content source IDs
82
+ */
83
+ export async function updateSiteMapEntriesWithContentChanges({
84
+ siteMapEntryGroups,
85
+ contentChanges,
86
+ stackbitConfig,
87
+ contentSourceDataById
88
+ }: {
89
+ siteMapEntryGroups: SiteMapEntryGroups;
90
+ contentChanges: ContentStoreTypes.ContentChangeResult;
91
+ stackbitConfig: Config | null;
92
+ contentSourceDataById: Record<string, ContentStoreTypes.ContentSourceData>;
93
+ }): Promise<SiteMapEntryGroups> {
94
+ if (!stackbitConfig?.siteMap) {
95
+ return {};
96
+ }
97
+
98
+ // Create a map of changed documents by content source id
99
+ const changedDocumentsByContentSourceId = contentChanges.updatedDocuments.reduce(
100
+ (accum: Record<string, ContentStoreTypes.Document[]>, contentChangeResultItem) => {
101
+ const contentSourceId = getContentSourceId(contentChangeResultItem.srcType, contentChangeResultItem.srcProjectId);
102
+ const document = contentSourceDataById[contentSourceId]?.documentMap[contentChangeResultItem.srcObjectId];
103
+ if (document) {
104
+ append(accum, contentSourceId, document);
105
+ }
106
+ return accum;
107
+ },
108
+ {}
109
+ );
110
+
111
+ // Create siteMap parameters from changed documents
112
+ const partialSiteMapOptions = _.reduce(
113
+ contentSourceDataById,
114
+ (accum: { models: CSITypes.ModelWithSource[]; documents: CSITypes.DocumentWithSource[] }, contentSourceData) => {
115
+ const changedDocuments = changedDocumentsByContentSourceId[contentSourceData.id] ?? [];
116
+ return {
117
+ models: accum.models.concat(
118
+ contentSourceData.models.map((model) => ({
119
+ srcType: contentSourceData.srcType,
120
+ srcProjectId: contentSourceData.srcProjectId,
121
+ ...model
122
+ }))
123
+ ),
124
+ documents: accum.documents.concat(mapStoreDocumentsToCSIDocumentsWithSource(changedDocuments))
125
+ };
126
+ },
127
+ { models: [], documents: [] }
128
+ );
129
+
130
+ const partialRawSiteMapEntries = await stackbitConfig.siteMap(partialSiteMapOptions);
131
+
132
+ // The partialRawSiteMapEntries entries are provided by the user, sanitize them and filter out illegal entries
133
+ const partialSiteMapEntryGroups = sanitizeAndGroupSiteMapEntries(partialRawSiteMapEntries);
134
+
135
+ siteMapEntryGroups = _.reduce(
136
+ contentChanges.deletedDocuments,
137
+ (accum, contentChangeResultItem: ContentStoreTypes.ContentChangeResultItem) => {
138
+ const siteMapGroupKey = `${contentChangeResultItem.srcType}:${contentChangeResultItem.srcProjectId}:${contentChangeResultItem.srcObjectId}`;
139
+ delete accum[siteMapGroupKey];
140
+ return accum;
141
+ },
142
+ siteMapEntryGroups
143
+ );
144
+
145
+ siteMapEntryGroups = _.reduce(
146
+ partialSiteMapEntryGroups,
147
+ (accum, newSiteMapEntriesByStableId, siteMapGroupKey) => {
148
+ accum[siteMapGroupKey] = newSiteMapEntriesByStableId;
149
+ return accum;
150
+ },
151
+ siteMapEntryGroups
152
+ );
153
+
154
+ return siteMapEntryGroups;
155
+ }
156
+
157
+ function sanitizeAndGroupSiteMapEntries(siteMapEntries: SiteMapEntry[]): SiteMapEntryGroups {
158
+ return siteMapEntries.reduce((accum: SiteMapEntryGroups, siteMapEntry) => {
159
+ if (!siteMapEntry) {
160
+ return accum;
161
+ }
162
+
163
+ if (typeof siteMapEntry.urlPath !== 'string') {
164
+ return accum;
165
+ }
166
+
167
+ if ('document' in siteMapEntry) {
168
+ const doc = siteMapEntry.document;
169
+ if (!doc.srcType || !doc.srcProjectId || !doc.modelName || !doc.id) {
170
+ return accum;
171
+ }
172
+ }
173
+
174
+ if (!siteMapEntry.stableId) {
175
+ siteMapEntry = {
176
+ ...siteMapEntry,
177
+ stableId: 'document' in siteMapEntry ? siteMapEntry.document.id : siteMapEntry.urlPath
178
+ };
179
+ }
180
+
181
+ const groupKey = getSiteMapGroupKey(siteMapEntry);
182
+ _.set(accum, [groupKey, siteMapEntry.stableId!], siteMapEntry);
183
+ return accum;
184
+ }, {});
185
+ }
186
+
187
+ function getSiteMapGroupKey(siteMapEntry: SiteMapEntry) {
188
+ return 'document' in siteMapEntry
189
+ ? `${siteMapEntry.document.srcType}:${siteMapEntry.document.srcProjectId}:${siteMapEntry.document.id}`
190
+ : SiteMapStaticEntriesKey;
191
+ }
192
+
193
+ export function getDocumentFieldLabelValueForSiteMapEntry({
194
+ siteMapEntry,
195
+ locale,
196
+ contentSourceDataById
197
+ }: {
198
+ siteMapEntry: SiteMapEntry;
199
+ locale?: string;
200
+ contentSourceDataById: Record<string, ContentStoreTypes.ContentSourceData>;
201
+ }): string | null {
202
+ if (!('document' in siteMapEntry)) {
203
+ return null;
204
+ }
205
+ const contentSourceId = getContentSourceId(siteMapEntry.document.srcType, siteMapEntry.document.srcProjectId);
206
+ const contentSourceData = contentSourceDataById[contentSourceId];
207
+ if (!contentSourceData) {
208
+ return null;
209
+ }
210
+ const labelFieldName = contentSourceData.modelMap[siteMapEntry.document.modelName]?.labelField;
211
+ const document = contentSourceData.documentMap[siteMapEntry.document.id];
212
+ if (!labelFieldName || !document) {
213
+ return null;
214
+ }
215
+ const labelField = document.fields[labelFieldName];
216
+ if (!labelField) {
217
+ return null;
218
+ }
219
+ const localizedLabelField = getDocumentFieldForLocale(labelField, locale);
220
+ if (!localizedLabelField || !('value' in localizedLabelField) || !localizedLabelField.value) {
221
+ return null;
222
+ }
223
+ return String(localizedLabelField.value);
224
+ }