はじめに
microCMSを使ってブログを作成する際、コードのハイライト、TypeScriptの型の絞り込みで困った点があったので、ここにまとめます。
html-react-parserとは
上記サイトを参考に話を進めていきます。
microCMSのブログ詳細ページは、post.contentに格納されており、データを取得した時点では文字列型になっています。
そのため、html-react-parserのparseメソッドを使用してJSX.Elementに変換します。
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文字列を、第二引数にはオプションを記述できます。
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で変換して置き換えることができます。
コードにハイライトする
まず、ハイライト用に記述したコードはこちらです。
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 }} />;
}
},
});
}
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>
);
}
それではまず、コードブロック要素がどのようになっているかを見ていきましょう。
<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 Elementinstead ofdomNode as Elementto prevent runtime errors.ランタイムエラーを防ぐため、
domNode as Elementではなく、domNode instanceof Elementでチェックすることが望ましいです。
あとは、hljs.highlight()でハイライトをするだけなので、説明を省略します。
Next.jsのコンポーネントに置き換える
先ほどの応用で、aをLinkに、imgをImageにしていきます。
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の学習につながりました。拙い文章、誤った内容が書かれているかもしれませんが、ご容赦ください。