@prismicio/types-internal
Version:
Prismic types for Custom Types and Prismic Data
410 lines (381 loc) • 10.4 kB
text/typescript
import { either } from "fp-ts"
import { isLeft, isRight } from "fp-ts/lib/Either"
import { pipe } from "fp-ts/lib/function"
import * as t from "io-ts"
import {
ContentPath,
TraverseSliceContentFn,
TraverseWidgetContentFn,
} from "../_internal/utils"
import { WidgetKey } from "../common"
import { UUID } from "../common/UUID"
import {
type StaticWidget,
collectSharedSlices,
flattenSections,
NestableWidget,
StaticCustomType,
} from "../customtypes"
import {
groupContentWithDefaultValues,
isGroupContent,
isSlicesContent,
migrateSliceItem,
NestableContentDefaultValue,
slicesContentWithDefaultValues,
traverseGroupContent,
traverseRepeatableContent,
traverseSlices,
traverseTableContent,
WidgetContent,
WidgetLegacy,
} from "./fields"
import {
defaultCtx,
FieldOrSliceType,
LegacyContentCtx,
WithTypes,
} from "./LegacyContentCtx"
export const Document = t.record(WidgetKey, WidgetContent)
export type Document = t.TypeOf<typeof Document>
const legacyDocReader = t.record(WidgetKey, t.unknown)
type DocumentLegacy = t.TypeOf<typeof legacyDocReader>
/**
* `DocumentLegacyCodec` handles decoding and encoding documents to the legacy
* format used by Prismic DB, therefore, this function itself is not "legacy".
*/
const DocumentLegacyCodec = (
allTypes?: LegacyContentCtx["allTypes"],
allKeys?: LegacyContentCtx["allKeys"],
) => {
return new t.Type<Document, WithTypes<DocumentLegacy>, unknown>(
"Document",
(u): u is Document => !!u && typeof u === "object",
(doc) => {
return pipe(
legacyDocReader.decode(doc),
either.map((parsedDoc) => {
return Object.entries(parsedDoc).reduce(
(acc, [widgetKey, widgetValue]) => {
const widgetCtx = defaultCtx(widgetKey, allTypes, allKeys)
const parsedW = WidgetLegacy(widgetCtx).decode(widgetValue)
if (!parsedW || isLeft(parsedW)) return acc
return { ...acc, [widgetKey]: parsedW.right }
},
{},
)
}),
)
},
(g: Document) => {
return Object.entries(g).reduce(
(acc, [key, value]) => {
const widgetCtx = defaultCtx(key, allTypes)
const result = WidgetLegacy(widgetCtx).encode(value)
if (!result) return acc
return {
content: { ...acc.content, [key]: result.content },
types: { ...acc.types, ...result.types },
keys: { ...acc.keys, ...result.keys },
}
},
{ content: {}, types: {}, keys: {} },
)
},
)
}
function extractMetadata(data: { [p: string]: unknown }): {
types: Map<string, FieldOrSliceType>
widgets: Partial<Record<WidgetKey, unknown>>
keys: Map<string, string>
slugs: ReadonlyArray<string>
uid: string | undefined
} {
const fields: [string, unknown][] = Object.entries(data)
const { types, widgets, keys } = fields.reduce(
(acc, [k, v]) => {
if (k.endsWith("_TYPE")) {
const decodedValue = FieldOrSliceType.decode(v)
if (isRight(decodedValue)) {
return {
...acc,
types: acc.types.set(
k.substring(0, k.length - 5),
decodedValue.right,
),
}
}
}
if (k.endsWith("_KEY")) {
const decodedValue = UUID.decode(v)
if (isRight(decodedValue)) {
return {
...acc,
keys: acc.keys.set(
k.substring(0, k.length - 4),
decodedValue.right,
),
}
}
}
if (
!k.endsWith("_POSITION") &&
!k.endsWith("_TYPE") &&
!k.endsWith("_KEY")
) {
return {
...acc,
widgets: {
...acc.widgets,
[k]: v,
},
}
}
return acc
},
{
types: new Map<string, FieldOrSliceType>(),
widgets: {},
keys: new Map<string, string>(),
},
)
const slugs = (data["slugs_INTERNAL"] as string[]) || []
const uid = data["uid"] as string | undefined
return {
widgets,
types,
keys,
uid,
slugs,
}
}
function parseLegacyDocument(
legacyDoc: unknown,
customType:
| StaticCustomType
| {
customTypeId: string
fields: Record<string, StaticWidget>
},
): Document | undefined {
const result = pipe(
// ensure it's the right document format first
t.record(WidgetKey, t.unknown).decode(legacyDoc),
either.chain((doc) => {
// extract all metadata, meaning all _TYPES keys from legacy format + the widgets as unknown
const { types, widgets, keys } = extractMetadata(doc)
// parse the actual widgets
return DocumentLegacyCodec(types, keys).decode(widgets)
}),
)
return isLeft(result) ? undefined : migrateDocument(result.right, customType)
}
function encodeToLegacyDocument(document: Document): DocumentLegacy {
const encoded = DocumentLegacyCodec().encode(document)
return { ...encoded.content, ...encoded.types, ...encoded.keys }
}
export const DocumentLegacy = {
_codec: DocumentLegacyCodec,
extractMetadata,
parse: parseLegacyDocument,
encode: encodeToLegacyDocument,
}
function simplifyCustomType(customType: StaticCustomType): {
customTypeId: string
fields: Record<string, StaticWidget>
} {
return {
customTypeId: customType?.id,
fields: Object.fromEntries(flattenSections(customType)),
}
}
export function fillDocumentWithDefaultValues(
customType:
| StaticCustomType
| {
customTypeId: string
fields: Record<string, StaticWidget>
},
document: Document,
): Document {
const { fields } =
customType && StaticCustomType.is(customType)
? simplifyCustomType(customType)
: customType
return Object.entries(fields).reduce<Document>(
(updatedDocument, [fieldKey, fieldDef]) => {
const fieldContent = document[fieldKey]
const updatedField = (() => {
switch (fieldDef.type) {
case "Group":
return isGroupContent(fieldContent)
? groupContentWithDefaultValues(fieldDef, fieldContent)
: fieldContent
case "Choice":
case "Slices":
return isSlicesContent(fieldContent)
? slicesContentWithDefaultValues(fieldDef, fieldContent)
: fieldContent
default:
return fieldContent === undefined && NestableWidget.is(fieldDef)
? NestableContentDefaultValue(fieldDef)
: fieldContent
}
})()
return updatedField
? {
...updatedDocument,
[fieldKey]: updatedField,
}
: updatedDocument
},
document,
)
}
/**
* @param model: Can be optional if we simply want to loop through the content
* without any consideration for the attached model
* @param document: The content we actually want to iterate on in an immutable fashion
* @param transform: A user function that provides a way to transform any kind
* of content wherever it is in a structured Prismic object content.
* @returns A transformed document with the user's transformation applied with
* the transform function
*/
export function traverseDocument({
document,
customType,
}: {
document: Document
customType?:
| StaticCustomType
| {
customTypeId: string
fields: Record<string, StaticWidget>
}
| undefined
}) {
const model =
customType && StaticCustomType.is(customType)
? simplifyCustomType(customType)
: customType
return ({
transformWidget = ({ content }) => content,
transformSlice = ({ content }) => content,
}: {
transformWidget?: TraverseWidgetContentFn
transformSlice?: TraverseSliceContentFn
}): Document => {
const fieldModels =
model &&
Object.entries(model.fields).reduce<Record<string, StaticWidget>>(
(acc, [key, def]) => ({ ...acc, [key]: def }),
{},
)
return Object.entries(document).reduce((acc, [key, content]) => {
const fieldModel = fieldModels && fieldModels[key]
const path = ContentPath.make([
{ key: model?.customTypeId, type: "CustomType" },
{ key, type: "Widget" },
])
const transformedWidget = (() => {
switch (content.__TYPE__) {
case "SliceContentType":
return traverseSlices({
path,
key,
model:
fieldModel?.type === "Slices" || fieldModel?.type === "Choice"
? fieldModel
: undefined,
content,
})({ transformWidget, transformSlice })
case "GroupContentType":
return traverseGroupContent({
path,
key,
apiId: key,
model: fieldModel?.type === "Group" ? fieldModel : undefined,
content,
})(transformWidget)
case "RepeatableContent":
return traverseRepeatableContent({
path,
key,
apiId: key,
model: fieldModel?.type === "Link" ? fieldModel : undefined,
content,
})(transformWidget)
case "TableContent":
return traverseTableContent({
path,
key,
apiId: key,
model: fieldModel?.type === "Table" ? fieldModel : undefined,
content,
})(transformWidget)
default:
return transformWidget({
path,
key,
apiId: key,
model:
fieldModel?.type !== "Group" &&
fieldModel?.type !== "Slices" &&
fieldModel?.type !== "Choice"
? fieldModel
: undefined,
content,
})
}
})()
return {
...acc,
...(transformedWidget ? { [key]: transformedWidget } : {}),
}
}, {})
}
}
// /**
// * The goal is to be able to collect all widgets or slices of a given type at any level of nesting inside a prismic content
// *
// * @param document parsed prismic content
// * @param is typeguard to match specifically the type of widget we want to collect
// * @returns a record containing the path of the widget as key and the typed collected content as value
// */
export function collectWidgets<W extends WidgetContent>(
document: Document,
is: (content: WidgetContent, path: ContentPath) => content is W,
): Record<string, W> {
const collected: Record<string, W> = {}
traverseDocument({ document })({
transformWidget: ({ content, path }) => {
const key = ContentPath.serialize(path)
if (is(content, path)) collected[key] = content
return content
},
})
return collected
}
export function migrateDocument(
document: Document,
customType:
| StaticCustomType
| {
customTypeId: string
fields: Record<string, StaticWidget>
},
) {
const model = StaticCustomType.is(customType)
? simplifyCustomType(customType)
: customType
const needsMigration = Object.values(collectSharedSlices(model)).some(
(slice) => Boolean(slice.legacyPaths),
)
if (!needsMigration) return document
return traverseDocument({
document,
customType,
})({
transformSlice: migrateSliceItem,
})
}