UNPKG

gatsby-source-prismic

Version:

Gatsby source plugin for building websites using Prismic as a data source

537 lines (512 loc) 14.5 kB
import * as prismicT from "@prismicio/types"; import * as RTE from "fp-ts/ReaderTaskEither"; import * as R from "fp-ts/Record"; import * as O from "fp-ts/Option"; import * as A from "fp-ts/Array"; import { pipe } from "fp-ts/function"; import { Stringable } from "gatsby-node-helpers"; import { Dependencies, PrismicSpecialType, TypePathKind, UnknownRecord, } from "../types"; import { getTypePath } from "./getTypePath"; import { createNodeOfType } from "./createNodeOfType"; import { mapRecordIndices } from "./mapRecordIndices"; import { createRemoteFileNode } from "./createRemoteFileNode"; import { PRISMIC_API_IMAGE_FIELDS } from "../constants"; import { removeURLParameter } from "./removeAutoURLParameter"; /** * Determines if a value is a record. * * @param value - Value to check. * * @returns `true` if the value is a record, `false` otherwise. */ const unknownRecordRefinement = (value: unknown): value is UnknownRecord => typeof value === "object" && value !== null && !Array.isArray(value); /** * Determines if a value is an array of records. * * @param value - Value to check. * * @returns `true` if the value is an array of records, `false` otherwise. */ const unknownRecordArrayValueRefinement = ( value: unknown, ): value is UnknownRecord[] => Array.isArray(value) && value.every(unknownRecordRefinement); /** * Determines if a value is a Prismic document. * * @param value - Value to check. * * @returns `true` if the value is a Prismic document, `false` otherwise. */ const documentRefinement = ( value: unknown, ): value is prismicT.PrismicDocument => unknownRecordRefinement(value) && "first_publication_date" in value; /** * Determines if a value is an Embed field. * * @param value - Value to check. * * @returns `true` if the value is an Embed field, `false` otherwise. */ const embedValueRefinement = (value: unknown): value is prismicT.EmbedField => unknownRecordRefinement(value) && "embed_url" in value; /** * Determines if a value is a Slice. * * @param value - Value to check. * * @returns `true` if the value is a Slice, `false` otherwise. */ const sliceValueRefinement = (value: unknown): value is prismicT.Slice => unknownRecordRefinement(value) && "slice_type" in value; /** * Determines if a value is a Slice. * * @param value - Value to check. * * @returns `true` if the value is a Slice, `false` otherwise. */ const sharedSliceValueRefinement = ( value: unknown, ): value is prismicT.SharedSlice => sliceValueRefinement(value) && "variation" in value; /** * Determines if a value is a Slice Zone. * * @param value - Value to check. * * @returns `true` if the value is a Slice Zone, `false` otherwise. */ const slicesValueRefinement = (value: unknown): value is prismicT.SliceZone => Array.isArray(value) && value.every(sliceValueRefinement); /** * Determines if the value is Stringable (has a `toString()` method). * * @param value - Value to check. * * @returns `true` if the value is Stringable, `false` otherwise. */ const stringableRefinement = (value: unknown): value is Stringable => (typeof value === "boolean" || typeof value === "number" || typeof value === "bigint" || typeof value === "string" || typeof value === "symbol" || typeof value === "function" || typeof value === "object") && value != null && Boolean(value.toString); /** * Determines if a value is a Link field. * * @param value - Value to check. * * @returns `true` if the value is a Link, `false` otherwise. */ export const linkValueRefinement = ( value: unknown, ): value is prismicT.LinkField => { return typeof value === "object" && (value === null || "link_type" in value); }; /** * Determines if a value is an Image field. * * @param value - Value to check. * * @returns `true` if the value is an Image, `false` otherwise. */ export const imageValueRefinement = ( value: unknown, ): value is prismicT.ImageField => { // Unfortunately, we can't check for specific properties here since it's // possible for the object to be empty if an image was never set. return typeof value === "object" && value !== null; }; /** * Normalizes a record within a Prismic document. It normalizes each field individually. * * @param path - Path to the record. * @param value - The record to normalize. * * @returns A normalized version of `value`. */ const normalizeDocumentRecord = ( kind: TypePathKind, path: string[], value: UnknownRecord, ): RTE.ReaderTaskEither<Dependencies, never, UnknownRecord> => pipe( RTE.ask<Dependencies>(), RTE.chain((deps) => pipe( value, mapRecordIndices(deps.transformFieldName), R.mapWithIndex((prop, propValue) => normalizeDocumentSubtree(kind, [...path, prop], propValue), ), R.sequence(RTE.ApplicativeSeq), ), ), ); /** * Traverses a subtree from a Prismic document and normalizes values as needed. * This function may process the subtree recursively. * * @param path - Path to the subtree. * @param value - The subtree to normalize. * * @returns A normalized version of `value`. */ export const normalizeDocumentSubtree = ( kind: TypePathKind, path: string[], value: unknown, ): RTE.ReaderTaskEither<Dependencies, never, unknown> => pipe( RTE.ask<Dependencies>(), RTE.bind("typePath", () => getTypePath(kind, path)), RTE.bind("type", (env) => RTE.right(env.typePath.type)), RTE.chain((env) => { switch (env.typePath.type) { case PrismicSpecialType.Document: { return pipe( value, RTE.fromPredicate( documentRefinement, () => new Error( `Field value does not match the type declared in its type path: ${env.type}`, ), ), RTE.bindTo("value"), RTE.bind("dataField", (scope) => !R.isEmpty(scope.value.data) ? normalizeDocumentRecord( TypePathKind.CustomType, [...path, "data"], scope.value.data, ) : RTE.right(undefined), ), RTE.chainW((scope) => RTE.right({ ...scope.value, data: scope.dataField, }), ), ); } case PrismicSpecialType.DocumentData: { return pipe( value, RTE.fromPredicate( unknownRecordRefinement, () => new Error( `Field value does not match the type declared in its type path: ${env.type}`, ), ), RTE.chainW((value) => normalizeDocumentRecord(TypePathKind.Field, path, value), ), ); } case prismicT.CustomTypeModelFieldType.Group: { return pipe( value, RTE.fromPredicate( unknownRecordArrayValueRefinement, () => new Error( `Field value does not match the type declared in its type path: ${env.type}`, ), ), RTE.map( A.map((item) => normalizeDocumentRecord(TypePathKind.Field, path, item), ), ), RTE.chainW(RTE.sequenceArray), ); } case prismicT.CustomTypeModelFieldType.Slices: { return pipe( value, RTE.fromPredicate( slicesValueRefinement, () => new Error( `Field value does not match the type declared in its type path: ${env.type}`, ), ), RTE.map( A.map((item) => sharedSliceValueRefinement(item) ? normalizeDocumentSubtree( TypePathKind.SharedSliceVariation, [item.slice_type, item.variation], item, ) : normalizeDocumentSubtree( TypePathKind.Field, [...path, item.slice_type], item, ), ), ), RTE.chainW(RTE.sequenceArray), ); } case prismicT.CustomTypeModelSliceType.Slice: { return pipe( value, RTE.fromPredicate( sliceValueRefinement, () => new Error( `Field value does not match the type declared in its type path: ${env.type}`, ), ), RTE.bindTo("value"), RTE.bindW("primary", (scope) => normalizeDocumentRecord( TypePathKind.Field, [...path, "primary"], scope.value.primary, ), ), RTE.bindW("items", (scope) => pipe( scope.value.items, A.map((item) => normalizeDocumentRecord( TypePathKind.Field, [...path, "items"], item, ), ), RTE.sequenceArray, ), ), RTE.map((scope) => ({ ...scope.value, primary: scope.primary, items: scope.items, })), ); } case PrismicSpecialType.SharedSliceVariation: { return pipe( value, RTE.fromPredicate( sharedSliceValueRefinement, () => new Error( `Field value does not match the type declared in its type path: ${env.type}`, ), ), RTE.bindTo("value"), RTE.bindW("primary", (scope) => normalizeDocumentRecord( TypePathKind.Field, [...path, "primary"], scope.value.primary, ), ), RTE.bindW("items", (scope) => pipe( scope.value.items, A.map((item) => normalizeDocumentRecord( TypePathKind.Field, [...path, "items"], item, ), ), RTE.sequenceArray, ), ), RTE.map((scope) => ({ ...scope.value, primary: scope.primary, items: scope.items, })), ); } case prismicT.CustomTypeModelFieldType.Embed: { return pipe( value, RTE.fromPredicate( embedValueRefinement, () => new Error( `Field value does not match the type declared in its type path: ${env.type}`, ), ), RTE.bindTo("value"), RTE.bind("id", (scope) => pipe( R.lookup("embed_url", scope.value), RTE.fromOption( () => new Error("Embed URL field does not exist"), ), RTE.filterOrElse( (url): url is string => typeof url === "string", () => new Error("Embed URL field is not a string"), ), RTE.map(env.nodeHelpers.createNodeId), ), ), RTE.chainW((scope) => // This type name matches the method used in // `buildEmbedFieldConfig`. createNodeOfType({ ...scope.value, id: scope.id }, "EmbedType"), ), RTE.map((node) => node.id), RTE.orElseW(() => RTE.right(null)), ); } case prismicT.CustomTypeModelFieldType.IntegrationFields: { return pipe( value, RTE.fromPredicate( unknownRecordRefinement, () => new Error("Field value is not a plain object"), ), RTE.bindTo("value"), RTE.bind("id", (scope) => pipe( scope.value, R.lookup("id"), O.filter(stringableRefinement), O.getOrElseW(() => env.createContentDigest(scope.value)), (id) => RTE.right(id), ), ), RTE.chainW((scope) => createNodeOfType({ ...scope.value, id: scope.id }, [ ...path, "IntegrationType", ]), ), RTE.map((node) => node.id), ); } case prismicT.CustomTypeModelFieldType.Link: { return pipe( value, RTE.fromPredicate( linkValueRefinement, () => new Error( `Field value does not match the type declared in its type path: ${env.type}`, ), ), RTE.bindTo("value"), RTE.bind("fileNode", (scope) => { return scope.value.link_type == prismicT.LinkType.Media && "url" in scope.value && scope.value.url ? createRemoteFileNode({ url: scope.value.url, path, field: scope.value, }) : RTE.right(null); }), RTE.map((scope) => ({ ...scope.value, localFile: scope.fileNode?.id || null, })), ); } case prismicT.CustomTypeModelFieldType.Image: { return pipe( value, RTE.fromPredicate( imageValueRefinement, () => new Error( `Field value does not match the type declared in its type path: ${env.type}`, ), ), RTE.bindTo("value"), RTE.bind("thumbnails", (scope) => pipe( scope.value, R.filterWithIndex<string, prismicT.ImageFieldImage>( (key) => !PRISMIC_API_IMAGE_FIELDS.includes(key), ), RTE.right, RTE.bindTo("thumbnails"), RTE.bind("thumbnailFileNodes", (thumbnailsScope) => pipe( thumbnailsScope.thumbnails, R.mapWithIndex((thumbnailName, thumbnail) => thumbnail.url ? createRemoteFileNode({ url: removeURLParameter(thumbnail.url, "auto"), path: [...path, thumbnailName], field: thumbnail, }) : RTE.right(null), ), R.sequence(RTE.ApplicativeSeq), ), ), RTE.map((scope) => pipe( scope.thumbnails, R.mapWithIndex((key, value) => ({ ...value, localFile: scope.thumbnailFileNodes[key]?.id || null, })), ), ), ), ), RTE.bind("fileNode", (scope) => { return scope.value.url ? createRemoteFileNode({ url: removeURLParameter(scope.value.url, "auto"), path, field: scope.value, }) : RTE.right(null); }), RTE.map((scope) => ({ ...scope.value, ...scope.thumbnails, localFile: scope.fileNode?.id || null, })), ); } case prismicT.CustomTypeModelFieldType.Boolean: case prismicT.CustomTypeModelFieldType.Color: case prismicT.CustomTypeModelFieldType.Date: case prismicT.CustomTypeModelFieldType.GeoPoint: case prismicT.CustomTypeModelFieldType.Number: case prismicT.CustomTypeModelFieldType.Select: case prismicT.CustomTypeModelFieldType.StructuredText: case prismicT.CustomTypeModelFieldType.Text: case prismicT.CustomTypeModelFieldType.Timestamp: case prismicT.CustomTypeModelFieldType.UID: case PrismicSpecialType.Unknown: default: { return RTE.throwError( new Error("Normalization not necessary for this value."), ); } } }), // If a normalizer fails or no normalizer exists for the subtree, keep the // subtree as is. RTE.orElse(() => RTE.right(value)), );