Next.jsで、生HTMLをJSXに変換してハイライトする方法

# はじめに
microCMSを使ってブログを作成する際、コードのハイライト、TypeScriptの型の絞り込みで困った点があったので、ここにまとめます。
# html-react-parserとは

https://blog.microcms.io/nextjs13-microcms-rsc/

上記サイトを参考に話を進めていきます。
microCMSのブログ詳細ページは、`post.content`に格納されており、データを取得した時点では文字列型になっています。
そのため、`html-react-parser`の`parse`メソッドを使用して`JSX.Element`に変換します。
```tsx:static/[postId]/page.tsx
export default async function StaticDetailPage({
 params: { postId },
}: {
 params: { postId: string };
}) {
const post = await getDetail(postId);

return <div>{parse(post.content)}</div>
```
しかし、このままではコードがハイライトされなかったり、`<a>`タグや`<img>`タグなどがそのままで、`Next.js`の恩恵を最大限に受けられません。
そこで、`html-react-parser`の`parse`メソッドにオプションを追加していきます。
## parse()
`parse`の第一引数には`html文字列`を、第二引数には`オプション`を記述できます。
```typescript
import parse, { HTMLReactParserOptions, Element } from 'html-react-parser';

const options: HTMLReactParserOptions = {
  replace(domNode) {
      // ...
    }
  },
};

const parsedContent = parse(post.content, options);
```
いくつかのオプションがありますが、今回は`replace`のみを使用していきます。
詳しくはGitHubリポジトリを参考にしてください。
[GitHub](https://github.com/remarkablemark/html-react-parser "html-react-parser")

`replace`では、コールバック関数を使用して、第一引数で受け取った各要素を判定して、任意の要素に置き換えることができます。これを利用して、microCMSで記述したコードブロックを`highlight.js`で変換して置き換えることができます。

# コードにハイライトする
まず、ハイライト用に記述したコードはこちらです。
```tsx:parseAndHighlight.ts
import parse, { Element, Text, domToReact } from "html-react-parser";
import HighlightCode from "./HighlightCode";

// 型ガード関数
const isElement = (element: unknown): element is Element => element instanceof Element;
const isText = (text: unknown): text is Text => text instanceof Text;

export default function parseAndHighlight(rawHtml: string) {
return parse(rawHtml, {
  replace: (domNode) => {
      // コードブロックであるか
      if (
      domNode instanceof Element &&
      domNode.name === "div" &&
      "data-filename" in domNode.attribs
      ) {
        // 型を絞り込んでいく
        if (!isElement(domNode.firstChild)) return;
        if (!isElement(domNode.firstChild.firstChild)) return;
        
        const codeElement = domNode.firstChild.firstChild;
        
        if (!isText(codeElement.firstChild)) return;

        // code本文, 言語名, ファイル名を抽出してハイライトする。
        const code = codeElement.firstChild.data;
        const languageClass = codeElement.attribs.class;
        const dataFileName = domNode.attribs["data-filename"];

        return <HighlightCode hlc={{ code, languageClass, dataFileName }} />;
      }
    },
  });
}

```
```tsx:HighlightCode.tsx
import parse from "html-react-parser";
import hljs from "highlight.js/lib/common";
import styles from "./HighlightCode.modue.scss"; // 詳細は省略
import "highlight.js/styles/lioshi.css";

type Props = {
  hlc: {
  code: string,
  languageClass: string,
  dataFileName: string,
  };
};

export default function HighlightCode({
  hlc: { code, languageClass, dataFileName },
}: Props) {
  // microCMSから取得したクラス名を、言語名に整形
  const language = languageClass.replace("language-", "");
  const highlightCode = hljs.highlight(code, {
    language: language,
    ignoreIllegals: true,
  }).value;
  
  return (
    <div className={styles.codeBlock}>
      <div className={styles.dataFileName}>{dataFileName}</div>
      <pre className={styles.code}>
        <code className={languageClass}>{parse(highlightCode)}</code>
      </pre>
    </div>
  );
}
```


それではまず、コードブロック要素がどのようになっているかを見ていきましょう。
```html:post.body
<div data-filename=\"ファイル名\">
    <pre>
        <code class=\"language-言語名\">
            <!-- codeの内容 -->
        </code>
    </pre>
</div>
```
microCMSで受け取ったコードブロックは、上記の構造になっているため、`domNode`の絞り込み条件はこのようになります。
```typescript
if (
  domNode instanceof Element &&
  domNode.name === "div" &&
  "data-filename" in domNode.attribs
) {
  //...
}
```
まず、`domNode instanceof Element`で、型をElementに絞り込みます。`domNode`は、
`Comment | Element | ProcessingInstruction | Text`のユニオン型となっており、`.name`や`.attribs`プロパティにアクセスすると型エラーが発生してしまいます。
そのため、型を絞り込む必要があるのです。
そして、タグが`div`で、属性に`data-filename`が含まれるものか判定します。

次に、子要素を取り出して必要な値を変数に代入します。
```tsx
// <pre>
if (!isElement(domNode.firstChild)) return;
// <code>
if (!isElement(domNode.firstChild.firstChild)) return;
const codeElement = domNode.firstChild.firstChild;

// codeの内容
if (!isText(codeElement.firstChild)) return;

// code本文, 言語名, ファイル名を抽出してハイライトする。
const code = codeElement.firstChild.data;
const languageClass = codeElement.attribs.class;
const dataFileName = domNode.attribs["data-filename"];

return <HighlightCode hlc={{ code, languageClass, dataFileName }} />;
```
まず、なぜ`isElement`,`isText`を使用しているかですが、`firstChild`の型が、`ChildNode`となっており、`Element`および`Text`と互換性がないためです。また、`ChildNode`のimport方法がわからなかったので型ガード関数の引数の型は`unknown`としています。

また、これを型アサーションで記述するとこうなります。
```typescript
const codeElement = ((domNode.firstChild as Element).firstChild) as Element;

const code = (codeElement.firstChild as Text).data;
const languageClass = codeElement.attribs.class;
const dataFileName = domNode.attribs["data-filename"];
```
こちらのほうが簡潔ですが、型アサーションをなるべく避けようと考え、先ほどのような書き方になりました。

:::note warn
`html-react-parser`は、型の絞り込みに型アサーションを使用する方法を紹介しています。[Type errors with v5.0.0 #1126 ](https://github.com/remarkablemark/html-react-parser/issues/1126#issuecomment-1784188447)
しかし、
>Preferably you should check `domNode instanceof Element` instead of `domNode as Element` to prevent runtime errors.
>
>ランタイムエラーを防ぐため、`domNode as Element` ではなく、`domNode instanceof Element` でチェックすることが望ましいです。

と書かれているように、[型アサーション](https://typescriptbook.jp/reference/values-types-variables/type-assertion-as)では型の安全性が保障されないため、ここでは[型ガード関数](https://typescriptbook.jp/reference/functions/type-guard-functions)を使うようにしています。
:::

あとは、`hljs.highlight()`でハイライトをするだけなので、説明を省略します。

# Next.jsのコンポーネントに置き換える
先ほどの応用で、`a`を`Link`に、`img`を`Image`にしていきます。
```tsx
export default function parseToNextJSX(rawHtml: string) {
  return parse(rawHtml, {
    replace: (domNode) => {
      // aタグの処理
      if (domNode instanceof Element && domNode.name === "a") {
        // httpが含まれる場合は触らない
        if (domNode.attribs.href.indexOf("http") !== -1) {
          return;
        }
        // それ以外はLinkタグに置き換える。
        const children = domNode.children.filter(
          // childrenの型をElement | Textとする
          (node): node is Element | Text => isElement(node) || isText(node)
        );
        return <Link href={domNode.attribs.href}>{domToReact(children)}</Link>;
      }

      // 画像の処理
      if (domNode instanceof Element && domNode.name === "img") {
        const atr = domNode.attribs;
        return (
          <Image
            src={atr.src}
            alt={atr.alt}
            width={Number(atr.width)}
            height={Number(atr.height)}
          ></Image>
        );
      }
    },
  });
}
      
```
# おわりに
今回、独学で学習を始めて約半年の、初めてのアウトプットとなりました。型の扱いについてかなり四苦八苦したので、typescriptの学習につながりました。拙い文章、誤った内容が書かれているかもしれませんが、ご容赦ください。