LoginSignup
1
1

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

Posted at

はじめに

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

html-react-parserとは

上記サイトを参考に話を進めていきます。
microCMSのブログ詳細ページは、post.contentに格納されており、データを取得した時点では文字列型になっています。
そのため、html-react-parserparseメソッドを使用してJSX.Elementに変換します。

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-parserparseメソッドにオプションを追加していきます。

parse()

parseの第一引数にはhtml文字列を、第二引数にはオプションを記述できます。

import parse, { HTMLReactParserOptions, Element } from 'html-react-parser';

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

const parsedContent = parse(post.content, options);

いくつかのオプションがありますが、今回はreplaceのみを使用していきます。
詳しくはGitHubリポジトリを参考にしてください。
GitHub

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

コードにハイライトする

まず、ハイライト用に記述したコードはこちらです。

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 }} />;
      }
    },
  });
}

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>
  );
}

それではまず、コードブロック要素がどのようになっているかを見ていきましょう。

post.body
<div data-filename=\"ファイル名\">
    <pre>
        <code class=\"language-言語名\">
            <!-- codeの内容 -->
        </code>
    </pre>
</div>

microCMSで受け取ったコードブロックは、上記の構造になっているため、domNodeの絞り込み条件はこのようになります。

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が含まれるものか判定します。

次に、子要素を取り出して必要な値を変数に代入します。

// <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としています。

また、これを型アサーションで記述するとこうなります。

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"];

こちらのほうが簡潔ですが、型アサーションをなるべく避けようと考え、先ほどのような書き方になりました。

html-react-parserは、型の絞り込みに型アサーションを使用する方法を紹介しています。Type errors with v5.0.0 #1126
しかし、

Preferably you should check domNode instanceof Element instead of domNode as Element to prevent runtime errors.

ランタイムエラーを防ぐため、domNode as Element ではなく、domNode instanceof Element でチェックすることが望ましいです。

と書かれているように、型アサーションでは型の安全性が保障されないため、ここでは型ガード関数を使うようにしています。

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

Next.jsのコンポーネントに置き換える

先ほどの応用で、aLinkに、imgImageにしていきます。

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の学習につながりました。拙い文章、誤った内容が書かれているかもしれませんが、ご容赦ください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1