npm package: @nacelle/rich-text-contentful-serializer
Description: Convert Contentful Rich Text to a valid Rich Text `rast` document
Author: Caleb Bodtorf
Latest version: 1.0.7

# `rich-text-contentful-serializer`

This package contains utilities to convert Contentful Rich Text to a Rich Text `rast` (Rich Text Abstract Syntax Tree) document.

Please refer to [the `rast` format docs](https://www.nacelle.com/docs/rich-text/rast) to learn more about the syntax tree format and the available nodes.

## Usage

The main utility in this package is `contentfulToRichText` which takes a Rich Text JSON and transforms it into a valid `rast` document.

`contentfulToRichText` returns a `Promise` that resolves with a Rich Text document.

```js
import { contentfulToRichText } from 'rich-text-contentful-serializer';

const richText = {
  nodeType: 'document',
  data: {},
  content: [
    {
      nodeType: 'heading-1',
      content: [
        {
          nodeType: 'text',
          value: 'Lorem ipsum dolor sit amet',
          marks: [],
          data: {},
        },
      ],
      data: {},
    },
};

contentfulToRichText(richText).then((richText) => {
  console.log(richText);
});
```

## Validate `rast` documents

`rast` is a strict format for Rich Text fields and follows a different pattern from Contentful Rich Text structure.

The `rich-text-utils` package provides a `validate` utility to validate a Rich Text content to make sure that it is compatible with Rich Text field.

```js
import { validate } from 'rich-text-utils';

// ...

contentfulToRichText(richText).then((richText) => {
  const { valid, message } = validate(richText);

  if (!valid) {
    throw new Error(message);
  }
});
```

We recommend to validate every `rast` to avoid errors later when creating records.

## Advanced Usage

### Options

All the `*ToRichText` utils accept an optional `options` object as second argument:

```js
type Options = Partial<{
  // Override existing Contentful node handlers or add new ones.
  handlers: Record<string, CreateNodeFunction>,
  // Array of allowed Block nodes.
  allowedBlocks: Array<
    BlockquoteType | CodeType | HeadingType | LinkType | ListType,
  >,
  // Array of allowed marks.
  allowedMarks: Mark[],
}>;
```

### Transforming Nodes

The utils in this library traverse a `Contentful Rich Text` tree and transform supported nodes to `rast` nodes. The transformation is done by working on a `Contentful Rich Text` node with a handler (async) function.

Handlers are associated to `Contentful Rich Text` nodes by `nodeType` and look as follow:

```js
import { visitChildren } from 'rich-text-contentful-serializer';

// Handler for the paragraph node type.
async function p(createrastNode, contentfulNode, context) {
  return createrastNode('paragraph', {
    children: await visitChildren(createrastNode, contentfulNode, context),
  });
}
```

Handlers can return either a promise that resolves to a `rast` node, an array of `rast` Nodes, or `undefined` to skip the current node.

To ensure that a valid `rast` is generated, the default handlers also check that the current `contentfulNode` is a valid `rast` node for its parent and, if not, they ignore the current node and continue visiting its children.

Information about the parent `rast` node name is available in `context.parentNodeType`.

Please take a look at the [default handlers implementation](./handlers.ts) for examples.

The default handlers are available on `context.defaultHandlers`.

### Context

Every handler receives a `context` object that includes the following information:

```js
export interface GlobalContext {
  // <base> tag url. This is used for resolving relative URLs.
  baseUrl?: string;
}

export interface Context {
  // The current parent `rast` node type.
  parentNodeType: NodeType;
  // The parent `Contentful Rich Text` node.
  parentNode: ContentfulNode;
  // A reference to the current handlers - merged default + user handlers.
  handlers: Record<string, Handler<unknown>>;
  // A reference to the default handlers record (map).
  defaultHandlers: Record<string, Handler<unknown>>;
  // Marks for span nodes.
  marks?: Mark[];
  // Array of allowed Block types.
  allowedBlocks: Array<
    BlockquoteType | CodeType | HeadingType | LinkType | ListType,
  >;
  // Array of allowed marks.
  allowedMarks: Mark[];
  // Properties in this object are available to every handler as Context
  // is not deeply cloned.
  global: GlobalContext;
}
```

### Custom Handlers

It is possible to register custom handlers and override the default behaviour via options:

```js
import { paragraphHandler } from './customHandlers';

contentfulToRichText(richText, {
  handlers: {
    paragraph: paragraphHandler,
  },
}).then((richText) => {
  console.log(richText);
});
```

It is **highly encouraged** to validate the `rast` when using custom handlers because handlers are responsible for dictating valid parent-children relationships and therefore generating a tree that is compliant with Rich Text.

## Preprocessing Rich Text

Because of the strictness of the `rast` spec, it is possible that some elements might be lost during transformation.

To improve the final result, you might want to modify the Rich Text tree before it is transformed to `rast`.

### Examples

<details>
  <summary>Split a node that contains an image.</summary>

In `rast`, images can only be presented as `Block` nodes, but blocks are not allowed inside of `ListItem` nodes (unordered-list/ordered-list). In this example we will split the original `unordered-list` in one list, the lifted up image block and another list.

```js
const richTextWithAssets = {
  nodeType: 'document',
  data: {},
  content: [
    {
      nodeType: 'unordered-list',
      content: [
        {
          nodeType: 'list-item',
          content: [
            {
              nodeType: 'paragraph',
              content: [
                {
                  nodeType: 'text',
                  value: 'text',
                  marks: [],
                  data: {},
                },
              ],
              data: {},
            },
            {
              content: [],
              data: {
                target: {
                  sys: {
                    id: 'zzz',
                    linkType: 'Asset',
                    type: 'Link',
                  },
                },
              },
              nodeType: 'embedded-asset-block',
            },
            {
              nodeType: 'paragraph',
              content: [
                {
                  nodeType: 'text',
                  value: 'text',
                  marks: [],
                  data: {},
                },
              ],
              data: {},
            },
          ],
          data: {},
        },
      ],
      data: {},
    },
  ],
};

// This function transforms the richText tree and moves the embedded-asset-block to root,
// splitting the list in two parts.

function liftAssets(richText) {
  const visit = (node, cb, index = 0, parents = []) => {
    if (node.content && node.content.length > 0) {
      node.content.forEach((child, index) => {
        visit(child, cb, index, [...parents, node]);
      });
    }

    cb(node, index, parents);
  };

  const liftedImages = new WeakSet();

  visit(richText, (node, index, parents) => {
    if (
      !node ||
      node.nodeType !== 'embedded-asset-block' ||
      liftedImages.has(node) ||
      parents.length === 1 // is a top level asset
    ) {
      return;
    }

    const imgParent = parents[parents.length - 1];

    imgParent.content.splice(index, 1);

    let i = parents.length;
    let splitChildrenIndex = index;
    const contentAfterSplitPoint = [];

    while (--i > 0) {
      const parent = parents[i];
      const parentsParent = parents[i - 1];

      contentAfterSplitPoint = parent.content.splice(splitChildrenIndex);

      splitChildrenIndex = parentsParent.content.indexOf(parent);

      let nodeInserted = false;

      if (i === 1) {
        splitChildrenIndex += 1;
        parentsParent.content.splice(splitChildrenIndex, 0, node);
        liftedImages.add(node);

        nodeInserted = true;
      }

      splitChildrenIndex += 1;

      if (contentAfterSplitPoint.length > 0) {
        parentsParent.content.splice(splitChildrenIndex, 0, {
          ...parent,
          content: contentAfterSplitPoint,
        });
      }
      // Remove the parent if empty
      if (parent.content.length === 0) {
        splitChildrenIndex -= 1;
        parentsParent.content.splice(
          nodeInserted ? splitChildrenIndex - 1 : splitChildrenIndex,
          1,
        );
      }
    }
  });
}

liftAssets(richTextWithAssets);

const handlers = {
  'embedded-asset-block': async (createNode, node, context) => {
    const entry = '123';
    return createNode('block', {
      entry,
    });
  },
};

const rast = await contentfulToRichText(richTextWithAssets, { handlers });
```

</details>

## License

MIT