@datocms/cma-client
Version:
JS client for DatoCMS REST Content Management API
407 lines (367 loc) • 14.5 kB
text/typescript
/**
* DatoCMS Block Field Value Processing Utilities
*
* This utility provides a unified interface for working with blocks embedded within DatoCMS field values.
* DatoCMS supports three field types that can contain blocks:
* - Modular Content fields: arrays of blocks
* - Single Block fields: a single block
* - Structured Text fields: complex document structures with embedded blocks
*
* The challenge this solves: Each field type stores blocks differently and requires different
* traversal logic, making it complex to perform operations like transformations, filtering,
* or searching across blocks regardless of their containing field type.
*
* This utility abstracts away these differences, providing a consistent API to:
* - Visit/iterate through all blocks in any field type
* - Transform blocks while preserving field structure
* - Filter blocks based on conditions
* - Search for specific blocks
* - Perform functional operations (map, reduce, some, every)
*
* All functions come in both sync and async variants to support different use cases,
* particularly useful when block transformations require async operations like API calls.
*/
import {
type TreePath,
collectNodesAsync,
filterNodesAsync,
mapNodes,
mapNodesAsync,
} from 'datocms-structured-text-utils';
import type {
BlockInRequest,
RichTextFieldValue,
RichTextFieldValueInNestedResponse,
RichTextFieldValueInRequest,
SingleBlockFieldValue,
SingleBlockFieldValueInNestedResponse,
SingleBlockFieldValueInRequest,
StructuredTextFieldValue,
StructuredTextFieldValueInNestedResponse,
StructuredTextFieldValueInRequest,
} from '../fieldTypes';
import type * as ApiTypes from '../generated/ApiTypes';
type PossibleRichTextValue =
| RichTextFieldValue
| RichTextFieldValueInRequest
| RichTextFieldValueInNestedResponse;
type PossibleSingleBlockValue =
| SingleBlockFieldValue
| SingleBlockFieldValueInRequest
| SingleBlockFieldValueInNestedResponse;
type PossibleStructuredTextValue =
| StructuredTextFieldValue
| StructuredTextFieldValueInRequest
| StructuredTextFieldValueInNestedResponse;
async function* iterateBlocksAsync(
fieldType: ApiTypes.Field['field_type'],
nonLocalizedFieldValue: unknown,
): AsyncGenerator<{ item: BlockInRequest; path: TreePath }> {
if (fieldType === 'rich_text') {
const richTextValue = nonLocalizedFieldValue as PossibleRichTextValue;
if (richTextValue) {
for (let index = 0; index < richTextValue.length; index++) {
const item = richTextValue[index]!;
yield { item, path: [index] };
}
}
return;
}
if (fieldType === 'single_block') {
const singleBlockValue = nonLocalizedFieldValue as PossibleSingleBlockValue;
if (singleBlockValue) {
yield { item: singleBlockValue, path: [] };
}
return;
}
if (fieldType === 'structured_text') {
const structuredTextValue =
nonLocalizedFieldValue as PossibleStructuredTextValue;
if (structuredTextValue) {
const foundNodes = await collectNodesAsync(
structuredTextValue.document,
async (node) => node.type === 'block' || node.type === 'inlineBlock',
);
for (const { node, path } of foundNodes) {
if (node.type === 'block' || node.type === 'inlineBlock') {
yield { item: node.item, path };
}
}
}
return;
}
}
/**
* Visit every block in a field value, calling the visitor function for each block found.
* Supports rich text, single block, and structured text field types.
*
* @param fieldType - The type of DatoCMS field definition that determines how the value is processed
* @param nonLocalizedFieldValue - The non-localized field value containing blocks to visit
* @param visitor - Asynchronous function called for each block. Receives the block item and its path
* @returns Promise that resolves when all blocks have been visited
*/
export async function nonRecursiveVisitBlocksInNonLocalizedFieldValueAsync(
fieldType: ApiTypes.Field['field_type'],
nonLocalizedFieldValue: unknown,
visitor: (item: BlockInRequest, path: TreePath) => Promise<void>,
): Promise<void> {
for await (const { item, path } of iterateBlocksAsync(
fieldType,
nonLocalizedFieldValue,
)) {
await visitor(item, path);
}
}
/**
* Transform blocks in a field value by applying a mapping function to each block.
* Creates a new field value structure with transformed blocks while preserving the original structure.
* Supports rich text, single block, and structured text field types.
*
* @param fieldType - The type of DatoCMS field definition that determines how the value is processed
* @param nonLocalizedFieldValue - The non-localized field value containing blocks to transform
* @param mapper - Synchronous function that transforms each block. Receives block item and path, returns new block
* @returns The new field value with transformed blocks
*/
export function nonRecursiveMapBlocksInNonLocalizedFieldValue(
fieldType: ApiTypes.Field['field_type'],
nonLocalizedFieldValue: unknown,
mapper: (item: BlockInRequest, path: TreePath) => BlockInRequest,
): unknown {
if (fieldType === 'rich_text') {
const richTextValue = nonLocalizedFieldValue as PossibleRichTextValue;
return richTextValue
? richTextValue.map((item, index) => mapper(item, [index]))
: richTextValue;
}
if (fieldType === 'single_block') {
const singleBlockValue = nonLocalizedFieldValue as PossibleSingleBlockValue;
return singleBlockValue ? mapper(singleBlockValue, []) : null;
}
if (fieldType === 'structured_text') {
const structuredTextValue =
nonLocalizedFieldValue as PossibleStructuredTextValue;
if (!structuredTextValue) {
return null;
}
return {
schema: 'dast',
document: mapNodes(
structuredTextValue.document,
(node, _parent, path) => {
if (node.type === 'block' || node.type === 'inlineBlock') {
return { ...node, item: mapper(node.item, path) };
}
return node;
},
),
};
}
return nonLocalizedFieldValue;
}
/**
* Transform blocks in a field value by applying a mapping function to each block.
* Creates a new field value structure with transformed blocks while preserving the original structure.
* Supports rich text, single block, and structured text field types.
*
* @param fieldType - The type of DatoCMS field definition that determines how the value is processed
* @param nonLocalizedFieldValue - The non-localized field value containing blocks to transform
* @param mapper - Asynchronous function that transforms each block. Receives block item and path, returns new block
* @returns Promise that resolves to the new field value with transformed blocks
*/
export async function nonRecursiveMapBlocksInNonLocalizedFieldValueAsync(
fieldType: ApiTypes.Field['field_type'],
nonLocalizedFieldValue: unknown,
mapper: (item: BlockInRequest, path: TreePath) => Promise<BlockInRequest>,
): Promise<unknown> {
if (fieldType === 'rich_text') {
const richTextValue = nonLocalizedFieldValue as PossibleRichTextValue;
return richTextValue
? await Promise.all(
richTextValue.map((item, index) => mapper(item, [index])),
)
: richTextValue;
}
if (fieldType === 'single_block') {
const singleBlockValue = nonLocalizedFieldValue as PossibleSingleBlockValue;
return singleBlockValue ? await mapper(singleBlockValue, []) : null;
}
if (fieldType === 'structured_text') {
const structuredTextValue =
nonLocalizedFieldValue as PossibleStructuredTextValue;
if (!structuredTextValue) {
return null;
}
return {
schema: 'dast',
document: await mapNodesAsync(
structuredTextValue.document,
async (node, _parent, path) => {
if (node.type === 'block' || node.type === 'inlineBlock') {
return { ...node, item: await mapper(node.item, path) };
}
return node;
},
),
};
}
return nonLocalizedFieldValue;
}
/**
* Find all blocks that match the predicate function.
* Searches through all blocks in the non-localized field value and returns all matches.
*
* @param fieldType - The type of DatoCMS field definition that determines how the value is processed
* @param nonLocalizedFieldValue - The non-localized field value containing blocks to search
* @param predicate - Asynchronous function that tests each block. Should return true for matching blocks
* @returns Promise that resolves to an array of objects, each containing a matching block and its path
*/
export async function nonRecursiveFindAllBlocksInNonLocalizedFieldValueAsync(
fieldType: ApiTypes.Field['field_type'],
nonLocalizedFieldValue: unknown,
predicate: (item: BlockInRequest, path: TreePath) => Promise<boolean>,
): Promise<Array<{ item: BlockInRequest; path: TreePath }>> {
const results: Array<{ item: BlockInRequest; path: TreePath }> = [];
for await (const { item, path } of iterateBlocksAsync(
fieldType,
nonLocalizedFieldValue,
)) {
if (await predicate(item, path)) {
results.push({ item, path });
}
}
return results;
}
/**
* Filter blocks in a field value, removing those that don't match the predicate.
* Creates a new field value containing only blocks that pass the predicate test.
* Preserves the original field value structure and hierarchy.
*
* @param fieldType - The type of DatoCMS field definition that determines how the value is processed
* @param nonLocalizedFieldValue - The non-localized field value containing blocks to filter
* @param predicate - Asynchronous function that tests each block. Blocks returning false are removed
* @returns Promise that resolves to the new field value with filtered blocks
*/
export async function nonRecursiveFilterBlocksInNonLocalizedFieldValueAsync(
fieldType: ApiTypes.Field['field_type'],
nonLocalizedFieldValue: unknown,
predicate: (item: BlockInRequest, path: TreePath) => Promise<boolean>,
): Promise<unknown> {
if (fieldType === 'rich_text') {
const filteredItems: BlockInRequest[] = [];
for await (const { item, path } of iterateBlocksAsync(
fieldType,
nonLocalizedFieldValue,
)) {
if (await predicate(item, path)) {
filteredItems.push(item);
}
}
return filteredItems;
}
if (fieldType === 'single_block') {
for await (const { item, path } of iterateBlocksAsync(
fieldType,
nonLocalizedFieldValue,
)) {
if (await predicate(item, path)) {
return item;
}
}
return null;
}
if (fieldType === 'structured_text') {
const structuredTextValue =
nonLocalizedFieldValue as PossibleStructuredTextValue;
if (!structuredTextValue) {
return null;
}
const filteredDocument = await filterNodesAsync(
structuredTextValue.document,
async (node, _parent, path) => {
if (node.type === 'block' || node.type === 'inlineBlock') {
return await predicate(node.item, path);
}
return true;
},
);
return filteredDocument
? {
schema: 'dast',
document: filteredDocument,
}
: null;
}
return nonLocalizedFieldValue;
}
/**
* Reduce all blocks in a field value to a single value by applying a reducer function.
* Processes each block in the non-localized field value and accumulates the results into a single value.
*
* @template R - The type of the accumulated result
* @param fieldType - The type of DatoCMS field definition that determines how the value is processed
* @param nonLocalizedFieldValue - The non-localized field value containing blocks to reduce
* @param reducer - Asynchronous function that processes each block and updates the accumulator
* @param initialValue - The initial value for the accumulator
* @returns Promise that resolves to the final accumulated value
*/
export async function nonRecursiveReduceBlocksInNonLocalizedFieldValueAsync<R>(
fieldType: ApiTypes.Field['field_type'],
nonLocalizedFieldValue: unknown,
reducer: (accumulator: R, item: BlockInRequest, path: TreePath) => Promise<R>,
initialValue: R,
): Promise<R> {
let accumulator = initialValue;
for await (const { item, path } of iterateBlocksAsync(
fieldType,
nonLocalizedFieldValue,
)) {
accumulator = await reducer(accumulator, item, path);
}
return accumulator;
}
/**
* Check if any block in the non-localized field value matches the predicate function.
* Returns true as soon as the first matching block is found (short-circuit evaluation).
*
* @param fieldType - The type of DatoCMS field definition that determines how the value is processed
* @param nonLocalizedFieldValue - The non-localized field value containing blocks to test
* @param predicate - Asynchronous function that tests each block. Should return true for matching blocks
* @returns Promise that resolves to true if any block matches, false otherwise
*/
export async function nonRecursiveSomeBlocksInNonLocalizedFieldValueAsync(
fieldType: ApiTypes.Field['field_type'],
nonLocalizedFieldValue: unknown,
predicate: (item: BlockInRequest, path: TreePath) => Promise<boolean>,
): Promise<boolean> {
for await (const { item, path } of iterateBlocksAsync(
fieldType,
nonLocalizedFieldValue,
)) {
if (await predicate(item, path)) {
return true;
}
}
return false;
}
/**
* Check if every block in the non-localized field value matches the predicate function.
* Returns false as soon as the first non-matching block is found (short-circuit evaluation).
*
* @param fieldType - The type of DatoCMS field definition that determines how the value is processed
* @param nonLocalizedFieldValue - The non-localized field value containing blocks to test
* @param predicate - Asynchronous function that tests each block. Should return true for valid blocks
* @returns Promise that resolves to true if all blocks match, false otherwise
*/
export async function nonRecursiveEveryBlockInNonLocalizedFieldValueAsync(
fieldType: ApiTypes.Field['field_type'],
nonLocalizedFieldValue: unknown,
predicate: (item: BlockInRequest, path: TreePath) => Promise<boolean>,
): Promise<boolean> {
return !(await nonRecursiveSomeBlocksInNonLocalizedFieldValueAsync(
fieldType,
nonLocalizedFieldValue,
async (item, path) => {
return !(await predicate(item, path));
},
));
}