import type { DocumentContent } from "../content/document" import { GroupContentType } from "../content/group" import type { GroupContent, GroupItemContent } from "../content/group" import { isNestableContent } from "../content/nestable" import { isRepeatableContentValue, RepeatableContentType } from "../content/repeatable" import type { RepeatableContent } from "../content/repeatable" import { RichTextContentType } from "../content/richText" import { CompositeSliceContentType, SharedSliceContentType } from "../content/slice" import type { CompositeSliceContent, SharedSliceContent } from "../content/slice" import { type CompositeSliceItemContent, type SharedSliceItemContent, type LegacySliceItemContent, type SliceItemContent, type SlicesContent, SlicesContentType, } from "../content/slices" import { TableContentType, type TableContent } from "../content/table" import type { WidgetContent } from "../content/widget" import type { StaticCustomTypeModel } from "../model/customType" import type { GroupModel, NestedGroupModel } from "../model/group" import type { LinkModel } from "../model/link" import type { NestableModel } from "../model/nestable" import type { CompositeSliceModel, LegacySliceModel, SharedSliceModel, SharedSliceVariationContentModel, SliceContentModel, } from "../model/slice" import type { StaticSlicesModel } from "../model/slices" import { TableCellModel } from "../model/table" import type { TableModel } from "../model/table" import type { StaticWidgetModel } from "../model/widget" import * as contentPath from "./contentPath" import * as customTypeModel from "./customTypeModel" type TraverseDocumentContentConfig = { model?: StaticCustomTypeModel transformWidget?: TraverseWidgetContentFunction transformSlice?: TraverseSliceContentFunction } export function traverseDocumentContent( document: DocumentContent, config: TraverseDocumentContentConfig, ): DocumentContent { const { model, transformWidget, transformSlice } = config // Remove tabs from custom type model const flatModel = model ? customTypeModel.flatten(model) : {} const traversed: DocumentContent = {} for (const [key, content] of Object.entries(document)) { const path = contentPath.make( { key: config.model?.id, type: "CustomType" }, { key, type: "Widget" }, ) const traversedContent = traverseWidgetContent(path, key, key, content, { model: flatModel[key], transformWidget, transformSlice, }) if (traversedContent) { traversed[key] = traversedContent } } return traversed } type TraverseSlicesContentConfig = { model?: StaticSlicesModel transformWidget?: TraverseWidgetContentFunction transformSlice?: TraverseSliceContentFunction } export function traverseSlicesContent( path: contentPath.Path, key: string, apiID: string, content: SlicesContent, config: TraverseSlicesContentConfig, ): SlicesContent | undefined { const { transformWidget = (args) => args.content, transformSlice = (args) => args.content } = config const traversed: SlicesContent["value"] = [] for (const sliceItemContent of content.value) { const sliceModel = config.model?.config?.choices?.[sliceItemContent.name] let sliceContentModel: SliceContentModel | undefined // Happy path, content and model are both shared slices if ( sliceItemContent.widget.__TYPE__ === SharedSliceContentType && sliceModel?.type === "SharedSlice" ) { const variationID = sliceItemContent.widget.variation const variationModel = sliceModel.variations.find((variation) => variation.id === variationID) if (variationModel) { sliceContentModel = { type: "SharedSlice", sliceName: sliceModel.id, variationID: variationModel.id, fields: { primary: variationModel.primary || {}, items: variationModel.items || {}, }, } } } // Migrated path, content is composite or legacy, model is shared slice if (!sliceContentModel) { const legacyPath = contentPath.append(contentPath.serialize(path), sliceItemContent.name) // Find the migrated slice model who's legacy path matches the content path const migratedSliceModel = Object.values(config.model?.config?.choices || {}).find( (model): model is SharedSliceModel => { return model.type === "SharedSlice" && Boolean(model.legacyPaths?.[legacyPath]) }, ) if (migratedSliceModel) { // Find the variation that matches the legacy path const variation = migratedSliceModel.variations.find((variation) => { return variation.id === migratedSliceModel.legacyPaths?.[legacyPath] }) if (variation) { sliceContentModel = { type: "SharedSlice", sliceName: migratedSliceModel.id, variationID: variation.id, fields: { primary: variation.primary || {}, items: variation.items || {}, }, } } } } // Non-migrated path, content and model are composite or legacy if (!sliceContentModel && sliceModel && sliceModel.type !== "SharedSlice") { sliceContentModel = sliceModel } let traversedSliceItemContent: SliceItemContent | undefined if (sliceItemContent.widget.__TYPE__ === SharedSliceContentType) { const model = sliceContentModel?.type === "SharedSlice" ? sliceContentModel : undefined traversedSliceItemContent = traverseSharedSliceContent( path.concat({ key: sliceItemContent.key, type: "SharedSlice" }), sliceItemContent.key, sliceItemContent.name, sliceItemContent as SharedSliceItemContent, { model, transformWidget, transformSlice }, ) } else if (sliceItemContent.widget.__TYPE__ === CompositeSliceContentType) { const model = sliceContentModel?.type === "Slice" || sliceContentModel?.type === "SharedSlice" ? sliceContentModel : undefined traversedSliceItemContent = traverseCompositeSliceContent( path.concat({ key: sliceItemContent.key, type: "Slice" }), sliceItemContent.key, sliceItemContent.name, sliceItemContent as CompositeSliceItemContent, { model, transformWidget, transformSlice }, ) } else { const model = sliceContentModel?.type !== "Slice" ? sliceContentModel : undefined traversedSliceItemContent = traverseLegacySliceContent( path.concat({ key: sliceItemContent.key, type: "LegacySlice" }), sliceItemContent.key, sliceItemContent.name, sliceItemContent as LegacySliceItemContent, { model, transformWidget, transformSlice }, ) } if (traversedSliceItemContent) { traversed.push(traversedSliceItemContent) } } return transformWidget({ path, key, apiID, content: { ...content, value: traversed }, model: config.model, }) } type TraverseSharedSliceContentConfig = { model?: SharedSliceVariationContentModel transformWidget?: TraverseWidgetContentFunction transformSlice?: TraverseSliceContentFunction } export function traverseSharedSliceContent( path: contentPath.Path, key: string, apiID: string, content: SharedSliceItemContent, config: TraverseSharedSliceContentConfig, ): SharedSliceItemContent | undefined { const { transformWidget = (args) => args.content, transformSlice = (args) => args.content } = config const traversedPrimary: SharedSliceContent["primary"] = {} for (const [key, fieldContent] of Object.entries(content.widget.primary)) { const model = config.model?.fields.primary?.[key] const traversedContent = traverseWidgetContent( path.concat({ key: "primary", type: "primary" }, { key, type: "Widget" }), key, key, fieldContent, { model, transformWidget }, ) if ( traversedContent && (isNestableContent(traversedContent) || traversedContent.__TYPE__ === GroupContentType) ) { traversedPrimary[key] = traversedContent } } const traversedItems = traverseGroupItemsContent( path.concat({ key: "items", type: "items" }), content.widget.items, { model: config.model?.fields.items, transformWidget }, ) return transformSlice({ path, key, apiID, content: { ...content, widget: { ...content.widget, primary: traversedPrimary, items: traversedItems }, }, model: config.model, }) } type TraverseCompositeSliceContentConfig = { model?: CompositeSliceModel | SharedSliceVariationContentModel transformWidget?: TraverseWidgetContentFunction transformSlice?: TraverseSliceContentFunction } export function traverseCompositeSliceContent( path: contentPath.Path, key: string, apiID: string, content: CompositeSliceItemContent, config: TraverseCompositeSliceContentConfig, ): CompositeSliceItemContent | SharedSliceItemContent | undefined { const { transformWidget = (args) => args.content, transformSlice = (args) => args.content } = config const primaryModel = config.model?.type === "SharedSlice" ? config.model?.fields.primary : config.model?.["non-repeat"] const traversedPrimary: CompositeSliceContent["nonRepeat"] = {} for (const [key, fieldContent] of Object.entries(content.widget.nonRepeat)) { const traversedContent = traverseWidgetContent( path.concat({ key: "non-repeat", type: "primary" }, { key, type: "Widget" }), key, apiID, fieldContent, { model: primaryModel?.[key], transformWidget }, ) if (traversedContent && isNestableContent(traversedContent)) { traversedPrimary[key] = traversedContent } } const itemsModel = config.model?.type === "SharedSlice" ? config.model?.fields.items : config.model?.repeat const traversedItems = traverseGroupItemsContent( path.concat({ key: "repeat", type: "items" }), content.widget.repeat, { model: itemsModel, transformWidget }, ) return transformSlice({ path, key, apiID, content: { ...content, widget: { ...content.widget, nonRepeat: traversedPrimary, repeat: traversedItems }, }, model: config.model, }) } type TraverseLegacySliceContentConfig = { model?: LegacySliceModel | SharedSliceVariationContentModel transformWidget?: TraverseWidgetContentFunction transformSlice?: TraverseSliceContentFunction } export function traverseLegacySliceContent( path: contentPath.Path, key: string, apiID: string, content: LegacySliceItemContent, config: TraverseLegacySliceContentConfig, ): LegacySliceItemContent | SharedSliceItemContent | undefined { const { model, transformWidget = (args) => args.content, transformSlice = (args) => args.content, } = config let legacySliceModel: LegacySliceModel | undefined if (content.widget.__TYPE__ === "GroupContentType") { if (model?.type === "Group") { legacySliceModel = model } else if (model?.type === "SharedSlice") { legacySliceModel = { type: "Group", config: { fields: model.fields.items } } } } else { if (model?.type === "SharedSlice") { const primaryModel = model.fields.primary?.[content.name] if (primaryModel?.type !== "Group") { legacySliceModel = primaryModel } } else if (model?.type !== "Group") { legacySliceModel = model } } const traversedContent = traverseWidgetContent(path, key, apiID, content.widget, { model: legacySliceModel, transformWidget, transformSlice, }) if ( traversedContent && (isNestableContent(traversedContent) || traversedContent.__TYPE__ === GroupContentType) ) { return transformSlice({ path, key, apiID, content: { ...content, widget: traversedContent }, model, }) } } type TraverseGroupContentConfig = { model?: GroupModel transformWidget: TraverseWidgetContentFunction } export function traverseGroupContent( path: contentPath.Path, key: string, apiID: string, content: GroupContent, config: TraverseGroupContentConfig, ): GroupContent | undefined { const { model, transformWidget } = config const traversed = traverseGroupItemsContent(path, content.value, { model: model?.config?.fields, transformWidget, }) return transformWidget({ path, key, apiID, content: { ...content, value: traversed }, model, }) } type TraverseGroupItemsContentConfig = { model?: Record transformWidget: TraverseWidgetContentFunction } export function traverseGroupItemsContent( path: contentPath.Path, content: GroupItemContent[], config: TraverseGroupItemsContentConfig, ): GroupItemContent[] { const { transformWidget } = config const traversed: GroupItemContent[] = [] for (const groupItemContent of content) { // Legacy v3 documents can have .value as a plain object or string // instead of the expected [key, content][] tuples. if (!Array.isArray(groupItemContent.value)) { continue } const groupItemPath = path.concat({ key: groupItemContent.key, type: "GroupItem" }) const traversedValue: GroupItemContent["value"] = [] for (const [key, content] of groupItemContent.value) { // In some edge cases v3 handled implicitly, content // could be undefined, so we need to skip such cases. if (!content) { continue } const model = config.model?.[key] const traversedContent = traverseWidgetContent( groupItemPath.concat({ key, type: "Widget" }), key, key, content, { model, transformWidget }, ) if ( traversedContent && (isNestableContent(traversedContent) || traversedContent.__TYPE__ === GroupContentType) ) { traversedValue.push([key, traversedContent]) } } traversed.push({ ...groupItemContent, value: traversedValue }) } return traversed } type TraverseRepeatableContentConfig = { model?: LinkModel transformWidget: TraverseWidgetContentFunction } export function traverseRepeatableContent( path: contentPath.Path, key: string, apiID: string, content: RepeatableContent, config: TraverseRepeatableContentConfig, ): RepeatableContent | undefined { const { model, transformWidget } = config const nonRepeatModel = model?.type === "Link" && model.config ? { ...model, config: { ...model.config, repeat: false } } : model const traversed: RepeatableContent["value"] = [] for (let index = 0; index < content.value.length; index++) { const traversedContent = traverseWidgetContent( path.concat({ key: index.toString(), type: "Widget" }), key, apiID, content.value[index], { model: nonRepeatModel, transformWidget }, ) if (traversedContent && isRepeatableContentValue(traversedContent)) { traversed.push(traversedContent) } } return transformWidget({ path, key, apiID, content: { ...content, value: traversed }, model, }) } type TraverseTableContentConfig = { model?: TableModel transformWidget: TraverseWidgetContentFunction } export function traverseTableContent( path: contentPath.Path, key: string, apiID: string, content: TableContent, config: TraverseTableContentConfig, ): TableContent | undefined { const { model, transformWidget } = config const traversed: TableContent["content"] = [] for (let rowIndex = 0; rowIndex < content.content.length; rowIndex++) { const traversedContent: TableContent["content"][number]["content"] = [] for (let cellIndex = 0; cellIndex < content.content[rowIndex].content.length; cellIndex++) { const cellPath = path.concat({ key: [rowIndex, cellIndex].join(","), type: "Widget" }) const cell = content.content[rowIndex].content[cellIndex] const transformed = transformWidget({ path: cellPath, key, apiID, content: cell.content, model: TableCellModel, }) if (transformed && transformed.__TYPE__ === RichTextContentType) { traversedContent.push({ ...cell, content: transformed }) } else { traversedContent.push({ ...cell, content: { __TYPE__: RichTextContentType, value: [], }, }) } } traversed.push({ ...content.content[rowIndex], content: traversedContent }) } return transformWidget({ path, key, apiID, content: { ...content, content: traversed }, model, }) } export type TraverseSliceContentFunction = (args: { path: contentPath.Path key: string apiID: string content: TContent model?: SliceContentModel }) => TContent | SharedSliceItemContent | undefined export type TraverseWidgetContentFunction< TContentTransformMode extends "preserve" | "widen" = "preserve", > = (args: { path: contentPath.Path key: string apiID: string content: TContent model?: StaticWidgetModel }) => (TContentTransformMode extends "preserve" ? TContent : WidgetContent) | undefined // Internals type TraverseWidgetContentConfig = { model?: StaticWidgetModel transformWidget?: TraverseWidgetContentFunction transformSlice?: TraverseSliceContentFunction } function traverseWidgetContent( path: contentPath.Path, key: string, apiID: string, content: WidgetContent, config: TraverseWidgetContentConfig, ): WidgetContent | undefined { const { transformWidget = (args) => args.content, transformSlice = (args) => args.content } = config if (content.__TYPE__ === SlicesContentType) { const model = config.model?.type === "Slices" || config.model?.type === "Choice" ? config.model : undefined return traverseSlicesContent(path, key, apiID, content, { transformWidget, transformSlice, model, }) } if (content.__TYPE__ === GroupContentType) { const model = config.model?.type === "Group" ? config.model : undefined return traverseGroupContent(path, key, apiID, content, { transformWidget, model }) } if (content.__TYPE__ === RepeatableContentType) { const model = config.model?.type === "Link" ? config.model : undefined return traverseRepeatableContent(path, key, apiID, content, { transformWidget, model }) } if (content.__TYPE__ === TableContentType) { const model = config.model?.type === "Table" ? config.model : undefined return traverseTableContent(path, key, apiID, content, { transformWidget, model }) } const model = config.model?.type !== "Slices" && config.model?.type !== "Choice" && config.model?.type !== "Group" && config.model?.type !== "Table" ? config.model : undefined return transformWidget({ path, key, apiID, content, model }) }