🧙‍♂️

Next.jsでmicroCMSの「richEditorFormat: 'object'」を良い感じに扱う

2022/12/09に公開約16,200字

リッチエディタのテキストはデフォルトではhtml文字列で返ってくる

例えば以下のような見出し、段落、リストが含まれるテキストをリッチエディタで入稿したとします。

このとき、apiで情報を取得すると以下のようなjsonが返ってきます。bodyにhtml文字列が設定されていることが分かります。

{
    "id": "xxxx",
    "createdAt": "2022-12-09T04:02:15.597Z",
    "updatedAt": "2022-12-09T04:02:15.597Z",
    "contents": [
        {
            "fieldId": "richEditor",
            "body": "<h2 id=\"h8c6a2995e0\">見出し2-1</h2><p>これは段落の1行目です。</p><p>これは段落の2行目です。</p><p>これは段落の3行目です。<a href=\"https://zenn.dev/\" target=\"_blank\" rel=\"noopener noreferrer\">zennのトップページのリンク</a>が含まれます</p><h2 id=\"hf3363ecc8e\">見出し2-2</h2><ul><li>これはリストの1行目です。</li><li>これはリストの2行目です。</li></ul>"
        }
    ],
}

※上記はapiに「本文」(id: contents)というスキーマを設定し、繰り返しフィールドでリッチエディタ形式で入稿した例です。繰り返しフィールドの利用例は以前以下で記事にしました。
https://zenn.dev/ebifran/articles/b3f988ccc43191

html文字列で取得することのメリットとデメリット

この場合、Next.jsではdangerouslySetInnerHTMLに上記のbodyの値を埋め込むことによってhtmlを描画することになります。

この方法はお手軽にhtmlの描画が出来ることがメリットですが、Next.js側で用意したcomponentが再利用しにくいというデメリットもあります。
特にnext/imageを使った画像の描画ができないため、html内のimgタグをパースして遅延読み込みをサポートするなどの対策をしないと、ページの初期読み込みが遅くなってしまうなどの影響があります。

また、html文字列をそのまま出力するだけではデザインが適用できないため、getStaticPropsでapiのレスポンスをUI側に渡す前に、html文字列をパースして各要素にclassを付与するなどの対応も必要です。

リッチエディタのテキストをオブジェクトで取得できる

上記のような課題を解決するため、microCMSのapiにはリッチエディタのレスポンスの形式を指定するためのrichEditorFormatというパラメータが存在します。
これにobjectを指定することで、テキストをhtml文字列ではなくオブジェクトの形式で受け取ることができます(デフォルトはhtml)。詳細は以下の公式ドキュメントをご覧ください。
https://document.microcms.io/content-api/get-list-contents#h3337cd7636

冒頭のリッチエディタの入稿内容で、パラメータにrichEditorFormat: 'object'を指定したレスポンスは以下のようになります。

{
    "id": "xxxx",
    "createdAt": "2022-12-09T04:02:15.597Z",
    "updatedAt": "2022-12-09T04:02:15.597Z",
    "contents": [
        {
            "fieldId": "richEditor",
            "body": {
                "contents": [
                    {
                        "type": "block",
                        "value": [
                            {
                                "type": "text",
                                "value": "見出し2-1",
                                "attributes": {}
                            }
                        ],
                        "attributes": {
                            "header": 2
                        }
                    },
                    {
                        "type": "textBlock",
                        "value": [
                            {
                                "type": "text",
                                "value": "これは段落の1行目です。",
                                "attributes": {}
                            },
                            {
                                "type": "text",
                                "value": "\n",
                                "attributes": {}
                            },
                            {
                                "type": "text",
                                "value": "これは段落の2行目です。",
                                "attributes": {}
                            },
                            {
                                "type": "text",
                                "value": "\n",
                                "attributes": {}
                            },
                            {
                                "type": "text",
                                "value": "これは段落の3行目です。",
                                "attributes": {}
                            },
                            {
                                "type": "text",
                                "value": "zennのトップページのリンク",
                                "attributes": {
                                    "link": "https://zenn.dev/",
                                    "target": "_blank",
                                    "rel": "noopener noreferrer"
                                }
                            },
                            {
                                "type": "text",
                                "value": "が含まれます",
                                "attributes": {}
                            }
                        ]
                    },
                    {
                        "type": "block",
                        "value": [
                            {
                                "type": "text",
                                "value": "見出し2-2",
                                "attributes": {}
                            }
                        ],
                        "attributes": {
                            "header": 2
                        }
                    },
                    {
                        "type": "block",
                        "value": [
                            {
                                "type": "text",
                                "value": "これはリストの1行目です。",
                                "attributes": {}
                            },
                            {
                                "type": "text",
                                "value": "これはリストの2行目です。",
                                "attributes": {}
                            }
                        ],
                        "attributes": {
                            "list": "bullet",
                            "indent": 0
                        }
                    }
                ]
            }
        }
    ],
}

この形式であればUI側で各html要素に対応するcomponentを出しわける対応が可能となり、パフォーマンスの最適化が行いやすくなります。生のhtml文字列として扱うよりもこちらを積極的に利用したいです。

オブジェクトの形式にややクセがあり扱いにくい

前述のオブジェクトをそのままUI側に渡したいところなのですが、実際に利用してみて扱いづらく感じた点がいくつかあります。

componentの出しわけが行いづらい

自分が確認する限り、contents直下のオブジェクトに設定されるtypeには以下の4種類が存在します。(自分が使っていない書式があるだけで他にもあるかも)

  • block (見出し、リスト、引用符など)
  • textBlock (段落)
  • image (画像)
  • embed (埋め込み)

その中で、例えばtype: blockであるオブジェクトには以下のようなパターンがあります。

  • 見出し要素
{
  "type": "block",
  "value": [
    {
      "type": "text",
      "value": "見出し2-1",
      "attributes": {}
    }
  ],
  "attributes": {
    "header": 2
  }
}
  • リスト要素
{
  "type": "block",
  "value": [
    {
      "type": "text",
      "value": "これはリストの1行目です。",
      "attributes": {}
    },
    {
      "type": "text",
      "value": "これはリストの2行目です。",
      "attributes": {}
    }
  ],
  "attributes": {
    "list": "bullet",
    "indent": 0
  }
}

上記に対して適切なcomponentの出しわけを行おうと思うと、トップレベルのtypeを見るだけでは判断できず、たとえばattributesheaderが存在するかどうか、といった判断が必要になります。
これが例えば以下のような形式で返ってくるならUI側で処理がしやすいですが、現状はそうなっていません。

  • 見出し要素
{
  "type": "heading",
  "value": "見出し2-1",
  "level": 2
}
  • リスト要素
{
  "type": "ul",
  "list": [
    "これはリストの1行目です。",
    "これはリストの2行目です。",
  ]
}

段落要素が扱いづらい

段落要素は、type: textBlockというオブジェクトで表現されています。段落が複数ある場合、以下のような形式で返ってきます。

{
  "type": "textBlock",
  "value": [
      {
          "type": "text",
          "value": "これは段落の1行目です。",
          "attributes": {}
      },
      {
          "type": "text",
          "value": "\n",
          "attributes": {}
      },
      {
          "type": "text",
          "value": "これは段落の2行目です。",
          "attributes": {}
      },
      {
          "type": "text",
          "value": "\n",
          "attributes": {}
      },
      {
          "type": "text",
          "value": "これは段落の3行目です。",
          "attributes": {}
      },
      {
          "type": "text",
          "value": "zennのトップページのリンク",
          "attributes": {
              "link": "https://zenn.dev/",
              "target": "_blank",
              "rel": "noopener noreferrer"
          }
      },
      {
          "type": "text",
          "value": "が含まれます",
          "attributes": {}
      }
  ]
}

扱いづらいと感じるのは、type: textBlockのオブジェクトが複数返ってくるのではなく、1つのオブジェクト内に複数の段落が改行コードを含んだ形で返ってくる点です。
1つの<p>タグ内で各段落を<br>で改行させるならこの形のほうが扱いやすいですが、それぞれの段落を1つの<p>タグとして処理したいと思うとこの形式では扱いづらいです。

不要なプロパティが含まれている

テキストを表すオブジェクトにはattributesというプロパティが含まれます。これは太字であればbold、リンクであればhrefなどhtml固有の属性情報が設定されていますが、プレーンなテキストの場合は何も設定されていません。
getStaticPropsで返すjsonはサイズが小さければ小さいほどいい(ページのパフォーマンスに影響する)ので、このようなプロパティは削除した状態でUI側に渡したいです。

リスト要素にリンク等を設定した場合のオブジェクトが微妙(未解決)

例えば箇条書きリストに対して以下のような装飾を加えてみます。

この状態をapiで取得すると、以下のようなオブジェクトが返ってきます。

{
  "type": "block",
  "value": [
    {
      "type": "text",
      "value": "このリストの中には",
      "attributes": {}
    },
    {
      "type": "text",
      "value": "太字",
      "attributes": {
        "bold": true
      }
    },
    {
      "type": "text",
      "value": "が含まれます",
      "attributes": {}
    },
    {
      "type": "text",
      "value": "このリストの中には",
      "attributes": {}
    },
    {
      "type": "text",
      "value": "取り消し線",
      "attributes": {
        "strike": true
      }
    },
    {
      "type": "text",
      "value": "が含まれます",
      "attributes": {}
    }
  ],
  "attributes": {
    "list": "bullet",
    "indent": 0
  }
}

上記の通り、リストの各行とリストに含まれるテキスト要素がフラットな配列で表現されてしまっている関係で、どこからどこまでが同じリストなのかが判断できない状態になっています。

これはmicroCMSがapiのレスポンス形式を変えてくれないと対応できないので、私の場合はリスト要素にテキストの装飾を加えないという運用で対応しています。

レスポンスを良い感じに処理してUI側に渡す

上記を踏まえて、apiから返ってくるリッチエディタのレスポンスを少し加工してUI側に渡す、ということをしたいと思います。

型定義の作成

まずはタイプセーフにコードを書けるようにするため、apiのレスポンスを表す型定義と、加工後のオブジェクトを表す型定義を作成します。

apiのレスポンスは以下のように定義しました。

/lib/MicroCMS/types.ts
/**
 * 繰り返しフィールド:リッチエディタをobject形式で取得する場合のフォーマット
 */
export type RichEditorObjectFieldType = {
  fieldId: "richEditor";
  body: { contents: ContentType[] };
};

export type ContentType =
  | BlockContentType
  | ImageContentType
  | TextBlockContentType
  | EmbedContentType;
  
/**
 * ブロック要素
 */
export type BlockContentType = {
  type: "block";
  value: TextValue[];
  attributes: {
    header?: number;
    blockquote?: boolean;
    list?: "ordered" | "bullet";
    indent?: number;
  };
};

/**
 * 画像要素
 */
export type ImageContentType = {
  type: "image";
  value: string;
  attributes: {
    width: number;
    height: number;
  };
};

/**
 * 埋め込み要素
 */
export type EmbedContentType = {
  type: "embed";
  value: string;
};

/**
 * pタグ要素
 */
export type TextBlockContentType = {
  type: "textBlock";
  value: TextValue[];
};

/**
 * pタグ要素内のテキスト
 */
export type TextValue = {
  type: "text";
  value: string;
  attributes: TextAttribute & LinkAttribute;
};

/**
 * テキストに設定される可能性のある属性(ほかにもあるかもしれないが最低限必要な書式分のみ定義)
 */
export type TextAttribute = {
  bold?: boolean;
  italic?: boolean;
  underline?: boolean;
  strike?: boolean;
  background?: string;
  color?: string;
};

/**
 * リンクに設定される属性
 */
export type LinkAttribute = {
  link?: string;
  target?: string;
  rel?: string
};

加工後のオブジェクトを表す型定義は以下のようにしました。UI側でcomponentの出し分けが行いやすいような定義にしています。

/types/index.ts
/**
 * リッチエディタでサポートしている形式
 */
export type RichEditorContent =
  | HeadingContent
  | ParaContent
  | UnOrderedListContent
  | OrderedListContent
  | BlockquoteContent
  | ImageContent
  | EmbedContent;
  
/**
 * 見出し
 */
export type HeadingContent = {
  type: "heading";
  value: string;
  level: number;
};

type ListContent = {
  list: string[];
};

/**
 * 箇条書きリスト
 */
export type UnOrderedListContent = {
  type: "ul";
} & ListContent;

/**
 * 番号付きリスト
 */
export type OrderedListContent = {
  type: "ol";
} & ListContent;

/**
 * 引用符
 */
export type BlockquoteContent = {
  type: "bq";
  values: (PlainText | NewLineText)[];
};

/**
 * 画像
 */
export type ImageContent = {
  type: "img";
  src: string;
  width: number;
  height: number;
};

/**
 * 埋め込み
 */
export type EmbedContent = {
  type: "embed";
  html: string;
};

/**
 * 段落
 */
export type ParaContent = {
  type: "para";
  /**
   * このオブジェクトは複数のpタグを表現するので二次元配列
   */
  values: InnerTextValue[][];
};

export type InnerTextValue = PlainText | LinkText | StrikeText | BoldText;

type Text = {
  value: string;
};

/**
 * プレーンテキスト
 */
export type PlainText = {
  type: "plain";
  value: string;
} & Text;

/**
 * リンク
 */
export type LinkText = {
  type: "link";
  href: string;
} & Text;

/**
 * 打消し
 */
export type StrikeText = {
  type: "strike";
} & Text;

/**
 * 太字
 */
export type BoldText = {
  type: "bold";
} & Text;

/**
 * 改行
 */
export type NewLineText = {
  type: "br";
};

オブジェクトの変換処理

microCMSのapiから帰ってくるリッチエディタのオブジェクトを、上記で定義したオブジェクトに加工する処理です。
長いですが、やっていることはtypeattributesをもとに適切なjsonを返すという単純な処理です。

/lib/MicroCMS/utils.ts
export const CreateRichEditorContents = (
  field: RichEditorObjectFieldType
): RichEditorContent[] => {
  return field.body.contents.map((content) => {
    if (content.type === "block") {
      return CreateBlockContent(content);
    }

    if (content.type === "textBlock") {
      const innerTexts = CreateInnerTextList(content.value);

      return {
        type: "para",
        values: innerTexts,
      } as ParaContent;
    }

    if (content.type === "image") {
      return {
        type: "img",
        src: content.value,
        width: content.attributes.width,
        height: content.attributes.height,
      } as ImageContent;
    }

    if (content.type === "embed") {
      return {
        type: "embed",
        html: content.value,
      } as EmbedContent;
    }

    throw new Error("RichEditorContent で変換できない値が渡されました");
  });
};

const CreateBlockContent = (
  content: BlockContentType
):
  | HeadingContent
  | BlockquoteContent
  | UnOrderedListContent
  | OrderedListContent => {
  if (content.attributes.header) {
    return {
      type: "heading",
      level: content.attributes.header,
      value: content.value[0].value,
    };
  }

  if (content.attributes.blockquote) {
    const values = content.value.map((c) =>
      c.value === "\n"
        ? ({ type: "br" } as NewLineText)
        : ({ type: "plain", value: c.value } as PlainText)
    );

    return {
      type: "bq",
      values,
    };
  }

  if (content.attributes.list) {
    const list = content.value.map((x) => x.value);

    return content.attributes.list === "bullet"
      ? { type: "ul", list }
      : { type: "ol", list };
  }

  throw new Error("CreateBlockContent で変換できない値が渡されました");
};

const CreateInnerTextList = (textValues: TextValue[]): InnerTextValue[][] => {
  const results: InnerTextValue[][] = [];

  // 1つ1つのhtmlタグの中身を表す要素
  let innerTexts: InnerTextValue[] = [];

  textValues.forEach((textValue) => {
    // 改行コードごとに要素分けるための処理
    if (textValue.value === "\n") {
      if (innerTexts.length !== 0) {
        // ループで詰め込んでいたinnerHtmlを返却結果に詰める
        results.push(innerTexts);

        // innerHtml初期化
        innerTexts = new Array();
      }

      // 改行コードは要素の中に入れたくないので中断
      return;
    }

    const innerHtml = CreateInnerText(textValue);
    innerTexts.push(innerHtml);
  });

  // 配列の最後の要素が改行でなかった場合は結果が詰められないので考慮
  if (innerTexts.length !== 0) {
    results.push(innerTexts);
  }

  return results;
};

const CreateInnerText = (textValue: TextValue): InnerTextValue => {
  if (textValue.attributes.link) {
    return {
      type: "link",
      href: textValue.attributes.link,
      value: textValue.value,
    } as LinkText;
  }

  if (textValue.attributes.bold) {
    return {
      type: "bold",
      value: textValue.value,
    } as BoldText;
  }

  if (textValue.attributes.strike) {
    return {
      type: "strike",
      value: textValue.value,
    } as StrikeText;
  }

  return {
    type: "plain",
    value: textValue.value,
  } as PlainText;
};

getStaticPropsで加工後のオブジェクトを返してレンダリングする

あとはgetStaticPropsでmicroCMSのapiをたたいた後、上記で用意したオブジェクトを加工する処理を呼び出して処理結果を返してやればOKです。

UI側のcomponentの出し分けは以下のようなコードになります。

{article.contents.map((content, i) => (
  <RichEditor key={i} {...content} />
))}

const RichEditor: React.FC<ArticleContent> = (content) => {
  return content.type === "heading" ? (
    // 見出し用component
  ) : content.type === "para" ? (
    // 段落用component
  ) : content.type === "ul" || content.type == "ol" ? (
    // リスト用component
  ) : content.type === "img" ? (
    // 画像用component
  ) : content.type === "bq" ? (
    // 引用符用component
  ) : content.type === "embed" ? (
    // 埋め込み用component
  ) : (
    <></>
  );
};

export default RichEditor;

まとめ

richEditorFormat: 'object'を指定した際のレスポンスを良い感じに処理する一例を記載しました。

レスポンスの形式がややイケてないと感じる点がありますが、ひょっとすると今後変更になる可能性があるかもしれません。
公式の情報をウォッチして何かあればこの記事も更新しようと思います。

Discussion

ログインするとコメントできます