前回、remarkプラグインでGFMのアラート記法を実現しました。
今回は、rehypeプラグインで単体URLのリンクカード化を実現していきます。
ここでいう単体URLのリンクカード化とは、外部リンクのURLだけ貼ると、タイトルや説明、サムネイルを表示するリッチなリンクカードに変換してくれる機能のことです。
https://example.com
ZennやQiitaのようなプラットフォームでよく見かけるあれです。
上述のような機能は、Astroにはデフォルトではサポートされていません。
前回も言及しましたが、
既製のプラグインを使う方法もありますが、仕組みさえわかればそれほど難しい実装ではありません。自分で作ればHTML構造、クラス名、アイコン、タイトルなど自由にカスタマイズ可能です。
remark だけでなく、rehype でも同様のことが言えると思います。
この記事では、rehypeプラグインをゼロから自作する方法を解説します。
個人サイト側の記事:
実際にプラグインを使ったときの表示は、下記URLでのみ見れます。
(プラグインを使ったときの表示以外、本文の内容は同じです。)
remarkとrehype、どちらでプラグインを作るか
前回の内容を再掲します。
AstroのMarkdown処理パイプラインは、unified というエコシステムの上に成り立っています。そのなかで主役となるのが remark と rehype の2つです。
- remark はMarkdownを扱うプロセッサです。入力したMarkdownテキストを mdast(Markdown Abstract Syntax Tree)と呼ばれるツリー構造に変換し、プラグインはそのツリーを自由に操作できます。「Markdownのテキストを読んで構造を変えたい」場合はremarkプラグインが適しています。
- rehype はHTMLを扱うプロセッサです。こちらは hast(Hypertext Abstract Syntax Tree)を操作します。mdastがhastに変換されたあとで動作するため、出力HTMLの構造を直接いじりたい場合に使います。
今回は rehype を選びます。リンクカードへの変換は「<p> タグの中に <a> タグだけがある」という状態を検出して置き換える処理です。この判定はHTMLのツリー上で行うほうが直感的で、タグ名や属性をそのまま条件に使えます。またOGPのフェッチという非同期処理も絡むため、HTMLが確定したあとのrehypeで一括処理するほうがシンプルです。
rehypeプラグインの作り方
remarkプラグインとほとんど同様ですが、rehypeプラグインの要点を整理すると次の通りです。
-
unist-util-visitで対象ノードをたどる - 「対象ノードを置き換える」または「新たなノードを追加する」
-
tagName/propertiesで直接HTMLの構造を制御する
プラグインの基本構造
remarkと同様、rehypeプラグインもツリーを受け取る関数を返す関数です。
Root などの型定義を mdast ではなく hast からインポートする点がremarkプラグインとの違いです。
import type { Root } from 'hast';
export default function rehypeMyPlugin() {
return (tree: Root) => {
// treeを操作する
};
}
ノードをたどる - unist-util-visit
unist-util-visit の使い方はremarkと同じです。
第2引数でノードタイプを指定します。ここでは 'element' を指定しているので、すべてのHTMLタグに対応するノードが対象になります。コールバックにはnode(対象ノード)、index(親の中での位置)、parent(親ノード)が渡されます。
import type { Element, Parent, Root } from 'hast';
import { visit } from 'unist-util-visit';
import { fetchOgp } from './fetch-ogp';
export default function rehypeMyPlugin() {
return (tree: Root) => {
visit(tree, 'element', (node: Element, index: number | undefined, parent: Parent | undefined) => {
// elementノードが見つかるたびに呼ばれる
});
};
ノードを置き換える / 子ノードに独自ノードを追加する
remarkと同様に parent.children[index] に新しいノードを代入します。
hastでは type: 'element' を使い、tagName でHTMLタグを指定します。
またremarkと違い、 data.hName / data.hProperties は不要です。tagName と properties が直接HTMLに対応します。
(parent.children as ElementContent[])[index] = {
type: 'element',
tagName: 'a',
properties: {
href,
className: ['link-card'],
},
children: [
{
type: 'element',
tagName: 'div',
properties: { className: ['link-card-body'] },
children: [
{
type: 'element',
tagName: 'h2',
properties: {},
children: [{ type: 'text', value: 'タイトル' }],
},
],
},
],
};
非同期処理との組み合わせ
visit のコールバックは同期関数として呼ばれます。コールバック内で await することはできません。
そのため、タスクをPromiseの配列に貯めてから、visitが終わったあとに Promise.all で一括処理するパターンを使います。
visit が走り終わった時点では tasks にPromiseが積まれているだけです。await Promise.all(tasks) で全件完了を待ってから処理を終えます。Astroのビルド時にプラグインが呼ばれる際も、このPromiseが解決されるまで待ってくれます。
return async (tree: Root) => {
const tasks: Promise<void>[] = [];
visit(tree, 'element', (node, index, parent) => {
// 非同期処理が必要なら tasks に積む
tasks.push(
someAsyncOperation().then((result) => {
// ノードを書き換える
})
);
});
// すべての非同期処理が終わるまで待つ
await Promise.all(tasks);
};
実装したプラグイン
実装したプラグインは次の通りです。
実装
OGPのフェッチ処理については詳しく言及しませんが、`cheerio` を使っています。
import type { Element, ElementContent, Parent, Root } from 'hast';
import { visit } from 'unist-util-visit';
import { fetchOgp } from './fetch-ogp';
export default function rehypeLinkCard() {
return async (tree: Root) => {
const tasks: Promise<void>[] = [];
visit(tree, 'element', (node: Element, index: number | undefined, parent: Parent | undefined) => {
if (!parent || index === undefined) return;
if (node.tagName !== 'p') return;
if (node.children.length !== 1) return;
const firstChild = node.children[0];
if (firstChild.type !== 'element' || firstChild.tagName !== 'a') return;
const href = firstChild.properties?.href;
if (typeof href !== 'string') return;
tasks.push(
fetchOgp(href).then((ogp) => {
(parent.children as ElementContent[])[index] = {
type: 'element',
tagName: 'a',
properties: {
href,
target: '_blank',
rel: 'noopener noreferrer',
className: ['link-card', 'anim-card-link'],
},
children: [
...(ogp.image
? [
{
type: 'element' as const,
tagName: 'figure',
properties: {},
children: [
{
type: 'element' as const,
tagName: 'img',
properties: { src: ogp.image, alt: ogp.title },
children: [],
},
],
},
]
: []),
{
type: 'element',
tagName: 'div',
properties: { className: ['link-card-body'] },
children: [
{
type: 'element',
tagName: 'div',
properties: { className: ['link-card-title'] },
children: [{ type: 'text', value: ogp.title }],
},
{
type: 'element',
tagName: 'p',
properties: { className: ['link-card-description'] },
children: [{ type: 'text', value: ogp.description }],
},
{
type: 'element',
tagName: 'p',
properties: { className: ['link-card-site'] },
children: [{ type: 'text', value: ogp.siteName }],
},
],
},
],
};
}),
);
});
await Promise.all(tasks);
};
}
【補足】 Astroへの組み込み
作成したプラグインは、astro.config.mjs に次の通り実装すると使えるようになります。
import { unified } from '@astrojs/markdown-remark';
import remarkLinkCard from './path/to/directory/rehype-link-card';
export default defineConfig({
markdown: {
processor: unified({
rehypePlugins: [rehypeLinkCard],
}),
},
});
まとめ
remarkプラグインと同様にrehypeプラグインも仕組みを理解してしまえば、アラートの種類の追加やHTML構造の変更も自由自在です。既製プラグインをそのまま使うより、一度自分で実装してみることをおすすめします。