gatsby-source-prismic
Version:
Gatsby source plugin for building websites using Prismic as a data source
537 lines (512 loc) • 14.5 kB
text/typescript
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)),
);