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