import _ from 'lodash'; import { Config } from '@stackbit/sdk'; import { append } from '@stackbit/utils'; import { SiteMapEntry } from '@stackbit/types'; import * as CSITypes from '@stackbit/types'; import * as ContentStoreTypes from '../types'; import { mapStoreDocumentsToCSIDocumentsWithSource } from './store-to-csi-docs-converter'; import { getContentSourceId, getDocumentFieldForLocale, getObjectId } from '../content-store-utils'; export const SiteMapStaticEntriesKey = Symbol.for('SiteMapStaticEntriesKey'); export type SiteMapEntriesSourceKeys = string | symbol; /** * SiteMapEntryGroups is a two level hashmap. * If the SiteMapEntry is document-related, the first level key will be an * identifier of the document including content source, and the second key will * be the stableId. If the SiteMapEntry is static entry, the first key will * be a constant Symbol and the second key will be the stableId. * { * `{srcType}:{srcProjectId}:{srcDocumentId}`: { * `{stableId}`: SiteMapEntry * }, * [SiteMapStaticEntriesKey]: { * `{stableId}`: SiteMapEntry * } * } */ export type SiteMapEntryGroups = Record>; export async function getSiteMapEntriesFromStackbitConfig({ stackbitConfig, contentSourceDataById, configDelegate }: { stackbitConfig: Config | null; contentSourceDataById: Record; configDelegate: CSITypes.ConfigDelegate; }): Promise { if (!stackbitConfig?.siteMap) { return {}; } const siteMapOptions = _.reduce( contentSourceDataById, (accum: { models: CSITypes.ModelWithSource[]; documents: CSITypes.DocumentWithSource[] }, contentSourceData) => { return { models: accum.models.concat( contentSourceData.models.map((model) => ({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, ...model })) ), documents: accum.documents.concat( mapStoreDocumentsToCSIDocumentsWithSource({ documents: contentSourceData.documents, csiDocumentMap: contentSourceData.csiDocumentMap }) ) }; }, { models: [], documents: [] } ); const rawSiteMapEntries = stackbitConfig.siteMap({ ...siteMapOptions, ...configDelegate }) ?? []; // The rawSiteMapEntries entries are provided by the user, sanitize them and filter out illegal entries return sanitizeAndGroupSiteMapEntries(rawSiteMapEntries); } /** * Because the sitemap is directly affected by documents, the sitemap can change * whenever there is a content change. For example, if a new document is added * or deleted, a new sitemap entry would be added or deleted respectively. * Likewise, if a slug of an existing document is changed, the sitemap entry for * that document would also change. * * However, to improve overall performance, we don't want to call * stackbitConfig.siteMap() with all the documents when a small set of documents * is changed. Instead, we want to call stackbitConfig.siteMap() with only the * changed documents. Then we merge the partial sitemap entries with the * existing sitemap entries using sitemap entry identifiers such as * srcDocumentId for document-related entries and stackbitId for static entries. * * @param siteMapEntries Existing sitemap entries * @param contentChanges A ContentChangeResult including new, changed and * deleted documents * @param stackbitConfig Stackbit config * @param contentSourceDataById ContentSourceData by content source IDs */ export async function updateSiteMapEntriesWithContentChanges({ siteMapEntryGroups, contentChanges, stackbitConfig, contentSourceDataById, configDelegate }: { siteMapEntryGroups: SiteMapEntryGroups; contentChanges: ContentStoreTypes.ContentChangeResult; stackbitConfig: Config | null; contentSourceDataById: Record; configDelegate: CSITypes.ConfigDelegate; }): Promise { if (!stackbitConfig?.siteMap) { return {}; } const updatedDocuments = [...contentChanges.createdDocuments, ...contentChanges.updatedDocuments]; if (updatedDocuments.length === 0 && contentChanges.deletedDocuments.length === 0) { return siteMapEntryGroups; } // Create a map of changed documents by content source id const changedDocumentsByContentSourceId = updatedDocuments.reduce((accum: Record, contentChangeResultItem) => { const contentSourceId = getContentSourceId(contentChangeResultItem.srcType, contentChangeResultItem.srcProjectId); const document = contentSourceDataById[contentSourceId]?.documentMap[contentChangeResultItem.srcObjectId]; if (document) { append(accum, [contentSourceId], document); } return accum; }, {}); // Create siteMap parameters from changed documents const partialSiteMapOptions = _.reduce( contentSourceDataById, (accum: { models: CSITypes.ModelWithSource[]; documents: CSITypes.DocumentWithSource[] }, contentSourceData) => { const changedDocuments = changedDocumentsByContentSourceId[contentSourceData.id] ?? []; return { models: accum.models.concat( contentSourceData.models.map((model) => ({ srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, ...model })) ), documents: accum.documents.concat( mapStoreDocumentsToCSIDocumentsWithSource({ documents: changedDocuments, csiDocumentMap: contentSourceData.csiDocumentMap }) ) }; }, { models: [], documents: [] } ); const partialRawSiteMapEntries = stackbitConfig.siteMap({ ...partialSiteMapOptions, ...configDelegate }) ?? []; // The partialRawSiteMapEntries entries are provided by the user, sanitize them and filter out illegal entries const partialSiteMapEntryGroups = sanitizeAndGroupSiteMapEntries(partialRawSiteMapEntries); siteMapEntryGroups = _.reduce( contentChanges.deletedDocuments, (accum, contentChangeResultItem: ContentStoreTypes.ContentChangeItem) => { const siteMapGroupKey = `${contentChangeResultItem.srcType}:${contentChangeResultItem.srcProjectId}:${contentChangeResultItem.srcObjectId}`; delete accum[siteMapGroupKey]; return accum; }, siteMapEntryGroups ); siteMapEntryGroups = _.reduce( partialSiteMapEntryGroups, (accum, newSiteMapEntriesByStableId, siteMapGroupKey) => { accum[siteMapGroupKey] = newSiteMapEntriesByStableId; return accum; }, siteMapEntryGroups ); return siteMapEntryGroups; } function sanitizeAndGroupSiteMapEntries(siteMapEntries: SiteMapEntry[]): SiteMapEntryGroups { return siteMapEntries.reduce((accum: SiteMapEntryGroups, siteMapEntry) => { if (!siteMapEntry) { return accum; } if (typeof siteMapEntry.urlPath !== 'string') { return accum; } if ('document' in siteMapEntry) { const doc = siteMapEntry.document; if (!doc.srcType || !doc.srcProjectId || !doc.modelName || !doc.id) { return accum; } siteMapEntry = { ...siteMapEntry, document: { id: doc.id, modelName: doc.modelName, srcType: doc.srcType, srcProjectId: doc.srcProjectId } }; } if (!siteMapEntry.stableId) { siteMapEntry = { ...siteMapEntry, stableId: 'document' in siteMapEntry ? siteMapEntry.document.id : siteMapEntry.urlPath }; } const groupKey = getSiteMapGroupKey(siteMapEntry); _.set(accum, [groupKey, siteMapEntry.stableId!], siteMapEntry); return accum; }, {}); } function getSiteMapGroupKey(siteMapEntry: SiteMapEntry): string { return 'document' in siteMapEntry ? getObjectId(siteMapEntry.document.srcType, siteMapEntry.document.srcProjectId, siteMapEntry.document.id) : SiteMapStaticEntriesKey.toString(); } export function getDocumentFieldLabelValueForSiteMapEntry({ siteMapEntry, locale, contentSourceDataById }: { siteMapEntry: SiteMapEntry; locale?: string; contentSourceDataById: Record; }): string | null { if (!('document' in siteMapEntry)) { return null; } const contentSourceId = getContentSourceId(siteMapEntry.document.srcType, siteMapEntry.document.srcProjectId); const contentSourceData = contentSourceDataById[contentSourceId]; if (!contentSourceData) { return null; } const document = contentSourceData.documentMap[siteMapEntry.document.id]; if (!document) { return null; } const { previewTitle } = document?.getPreview({ locale }) ?? {}; if (previewTitle) { return String(previewTitle); } const labelFieldName = contentSourceData.modelMap[siteMapEntry.document.modelName]?.labelField; if (labelFieldName) { const labelField = labelFieldName ? document?.fields[labelFieldName] : undefined; if (!labelField) { return null; } const localizedLabelField = getDocumentFieldForLocale(labelField, locale); if (!localizedLabelField || !('value' in localizedLabelField) || !localizedLabelField.value) { return null; } return String(localizedLabelField.value); } return null; }