はじめに
この記事はマークアップ言語変換インターフェイスunifiedについて解説、共有するためのものです。node.js環境でQiitaのマークダウンファイルをHTMLに変換する過程を題材に、unifiedを学習します。
対象とする環境
- node.js v16.18.0
- unified v10.1.2
- remark v14.0.2
- rehype v12.0.1
異なるバージョンをご利用の場合、この記事の内容がそのまま適用できないかもしれません。お手元の環境をご確認ください。
対象とする読者
- JavaScriptで開発をしたことがある
- node.jsを利用したことがある
- マークダウンを書いたことがある
- unifiedについては知らない / 名前ぐらいは知っている
unifiedインターフェイスとは
Qiitaの記事はマークダウン構文で書かれています。まずはこのマークダウンの構造を分解し、HTMLに変換する仕組みが必要です。今回はマークダウン変換にunifiedを使います。unifiedインターフェイスとは、HTML / XML / Markdownなどのマークアップ言語を相互変換することを目的としたプロジェクトです。
unified
unifiedはnode.jsで稼働するunifiedインターフェイスの中核です。各種マークアップ言語を構文ツリーに変換し、他のマークアップ言語に再変換します。unifiedは変換処理をAPIとして開放しており、ユーザーが自由にプラグインを作成して拡張できます。
remark
remarkは
- マークダウン言語を構文ツリーに変換するパーサープラグインremark-parse
- それらのプラグインをunifiedに統合したパッケージ
の名前です。名前の由来は、Markdownを再変換するのでre-markだと思われます。
両者は混同しやすいため、この記事では
- remark : remark-parseプラグイン
- remarkパッケージ : remark-parseプラグインを統合したパッケージ
と呼び分けます。
remarkパッケージは、remark-parse, remark-stringifyの2つのプラグインがunifiedに統合されいるため簡単にマークダウン→HTMLの変換処理が書けます。
import {remark} from 'remark'
main()
async function main() {
const file = await remark()
.process('# Hello, Neptune!')
console.log(String(file))
}
rehype
rehypeはremark同様
- HTMLを構文ツリーに変換するパーサープラグインrehype-parse
- それらのプラグインをunifiedに統合したパッケージ
の名前です。名前の由来は、ハイパーテキストを再変換するのでre-hypeだと思われます。
remarkとrehypeを組み合わせたサンプル
remark-parseとrehypeを組み合わせ、マークダウンファイルをHTMLに変換するサンプルコードは以下のようになります。
import {unified} from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
main()
async function main() {
const file = await unified()
.use(remarkParse) // マークダウン → mdast
.use(remarkRehype) // mdast → hast
.use(rehypeStringify) // hast → html
.process('# Hello, Neptune!') // 一連の処理にマークダウンを投入
console.log(String(file))
}
unifiedはまず、各種マークアップ言語を抽象構文ツリー(Abstract Syntax Tree : AST)に変換します。ASTのマークダウン実装がmdastです。
各種ASTは相互変換できます。mdastはremark-rehype
プラグインを通してHTML用ASTのhastに再変換されます。
最後にhastをHTMLに変換して出力します。
unifiedプラグイン
マークダウンからHTMLへの変換に成功しましたが、Qiitaでは以下のような装飾やマークダウンの拡張構文が利用されています。
- シンタックスハイライト
- リンクカード
- note記法
こうした拡張構文に対応するため、プラグインを導入します。
処理フローでの分類
実際にプラグインを導入する前に、unifiedプラグインがどのように分類されているかを解説します。unifiedプラグインは、どの処理フローで使われるかによって3つに分類されます。
- Parser
- Transformers
- Compiler
Parserは各種マークアップ言語をASTにパース(変換)する処理です。例としてremarkを構成するプラグインremark-parseはマークダウン言語をmdast(マークダウンAST)にパースします。
TransformersはASTを変形します。例としてhastにコードハイライトを追加するrehype-highlightがTrasfromersプラグインです。また、マークダウンの拡張構文プラグインもTransformersプラグインです。
CompilerはASTをマークアップ言語に再変換する処理です。hastをHTMLに変換する処理がこれにあたります。
同期 / 非同期トランスフォーマー
Transformersプラグインは、内部で非同期処理を使っているか否かによって以下の2つに分類されます。
- 同期トランスフォーマー
- 非同期トランスフォーマー
fetch
などの非同期処理を利用しているプラグインは、非同期トランスフォーマーになります。非同期トランスフォーマーはprocessSyncやrunSyncなどの同期処理に追加すると以下のエラーを返します。
Error: `processSync` finished async. Use `process` instead
プラグインを追加する
それでは実際にプラグインを追加して、マークダウン構文を拡張します。
rehype-highlight
rehype-highlightはhastを解析し、コードブロックにシンタックスハイライトを追加します。
モジュールをインストールし
npm install rehype-highlight
hast変換後のプロセスにプラグインを追加します。
import {unified} from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'// プラグインをimport
main()
async function main() {
const file = await unified()
.use(remarkParse) // マークダウン → mdast
.use(remarkRehype) // mdast → hast
.use(rehypeStringify) // hast → html
.use(rehypeHighlight) // hastにhighlight.jsでの装飾を追加
.process('# Hello, Neptune!') // 一連の処理にマークダウンを投入
console.log(String(file))
}
highlight.js用CSSクラスが追加されます。お好きなテーマを適用してシンタックスハイライトの完成です。
remark-link-card
マークダウン構文を拡張して、Qiitaのようなリンクカードを追加します。
remark-link-card
モジュールをインストールします。
npm i remark-link-card
remarkプラグインはmdastに対して変換をかけます。そのため、mdastの変換後、hastの変換前に追加します。
import {unified} from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import rlc from 'remark-link-card'// プラグインをimport
main()
async function main() {
const file = await unified()
.use(remarkParse) // マークダウン → mdast
.use(rlc) // リンクカード変換
.use(remarkRehype, { allowDangerousHtml:true }) // mdast → hast
.use(rehypeStringify, { allowDangerousHtml:true }) // hast → html
.process(`# remark-link-card\n\nhttp://example.com/`) // 一連の処理にマークダウンを投入
console.log(String(file))
}
allowDangerousHtml:trueとは?
remark-link-cardでは、リンクカードのHTMLを直接mdastノードに挿入しています。remark-rehypeおよびrehype-stringifyでは、セキュリティを維持するためmdastのHTMLノードを削除します。
allowDangerousHtml:true
オプションは、HTMLノード削除処理を一時的に停止させます。
remark-rehypeはなぜHTMLノードを削除するのか
マークダウンによる入力は、信頼できない第三者からのものである可能性があります。たとえばQiitaのような、ユーザーが自由に記事やコメントを投稿できるWebサービスの場合、悪意ある第三者が投稿内にスクリプトを挿入し、それを閲覧したユーザーが被害に遭うというシナリオが考えられます。そのためremark-rehypeは標準設定でHTMLノードを削除します。
プラグインを自作する
HTMLに変換するマークダウン記事が、常に信頼できる場合はallowDangerousHtmlオプションでの対処が可能です。また、remark-rehypeを挟まずremark単体で変換が完結する場合も、 HTMLノードの問題は発生しません。
しかし、信頼できない記事やコメントを取り扱い、なおかつremark-rehypeを通す場合には、プラグインを自作する必要があります。
自作プラグインの利用は攻撃の糸口となります。拡張構文解析に使う正規表現をターゲットにしたReDoS攻撃や、クロスサイトスクリプティング攻撃などが可能性として考えられます。利用シナリオを確認し、ご自身の責任で必要な対策を講じてください。
マークダウン構文を拡張するunifiedプラグインは
- mdastトランスフォーマー
- rehypeパーサーのハンドラー
の2つの要素で構成されます。
mdastトランスフォーマー
マークダウンで記述されたテキストを、mdastに変換するプラグインを作成します。unifiedには、プラグインでよく使う処理をまとめたユーティリティモジュールがあります。今回はその1つunist-util-visitを利用します。
import {visit} from 'unist-util-visit'
export default function retextSentenceSpacing() {
return (tree, file) => {
visit(tree, 'ParagraphNode', (node) => {
console.log(node)
})
}
}
//このサンプルで文節型のノードがconsoleに出力されます。
URLリンクのみの文節があったら、カスタム型LinkCard
ノードを作成し、ページのメタデータをpropertiesに埋め込みます。
import {visit} from 'unist-util-visit'
export default function remarkLinkCardPlugin() {
return async (tree) => {
const promises: any[] = []; // visitorを配列に格納
const visitor = (node, index, parent) => {
const children = [...node.children];
promises.push(async () => {
const url = children[0].value;
const meta = await MetadataScraper(url); // metaデータをfetchする
parent.children[index] = {
type: "LinkCard",
properties: {
className: [],
title: meta.title,
image: meta.image,
urlOrigin: new URL(meta.url).origin,
},
children,
};
});
};
visit(tree, this.isLink, visitor); // 第2引数にbooleanを返す関数を渡すと、nodeがプラグインの対象か否かを判定します。
await Promise.all(promises.map((t) => t())); // visitorの処理を待ちます
};
};
コードの全体はこちらをご確認ください。
rehypeパーサーのハンドラー
つぎに、mdastのカスタムノードをhastのノードに変換するハンドラーを作成します。
remark-rehype
はhandlerというインターフェイスで、カスタムノードの変換処理を受け取ります。
public static async convertToHTML(body: string) {
const result = await unified()
.use(remarkParse)
.use(remarkRehype, {
handlers: {
LinkCard: RemarkLinkCardPlugin.rehypeHandler,// ここがハンドラー。
},
})
.use(rehypeStringify)
.process(body);
return result.value;
}
ハンドラーは、mdastのtypeとハンドラーオプションのkeyが一致すると動作します。ハンドラーはhastのnodeを返す関数です。
const rehypeHandler = (h, node) => {
return {
type: "element",
tagName: "a",
properties: {
className: ["rlc-container"],
href: url,
},
children:[...子要素],
};
}
ハンドラーの第一引数h
はmdast-util-to-hastのインスタンスです。mdastのツリーとユーティリティ関数をプロパティとして持っていて、ノードツリーの再帰的な変換が必要な場合に利用します。今回はリンクカードの中にリンクカードがある場合を考えないので利用しません。
これでremarkとrehypeを利用した自作プラグインができました。
個人的な感想
unifiedを直接利用する機会は少ないですが、gatsby-transformer-remarkなどを通じて間接的に利用している場合があります。一度直接触ってみることで、各種サービスのパーサーがどのように動作しているのか理解が進みました。
参考記事
以上、ありがとうございました。