Blog

Astro - rehypeプラグインを作って単体URLのリンクカード化を実現する

前回、remarkプラグインでGFMのアラート記法を実現しました。

Astro - remarkプラグインを作ってGFMのアラート記法を実現する | R.dev

今回は、rehypeプラグインで単体URLのリンクカード化を実現していきます。

ここでいう単体URLのリンクカード化とは、外部リンクのURLだけ貼ると、タイトルや説明、サムネイルを表示するリッチなリンクカードに変換してくれる機能のことです。

https://example.com

ZennやQiitaのようなプラットフォームでよく見かけるあれです。

上述のような機能は、Astroにはデフォルトではサポートされていません。

前回も言及しましたが、

既製のプラグインを使う方法もありますが、仕組みさえわかればそれほど難しい実装ではありません。自分で作ればHTML構造、クラス名、アイコン、タイトルなど自由にカスタマイズ可能です。

remark だけでなく、rehype でも同様のことが言えると思います。

この記事では、rehypeプラグインをゼロから自作する方法を解説します。

remarkとrehype、どちらでプラグインを作るか

前回の内容を再掲します。

AstroのMarkdown処理パイプラインは、unified というエコシステムの上に成り立っています。そのなかで主役となるのが remarkrehype の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 は不要です。tagNameproperties が直接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);
};
}
マークダウン
https://example.com
https://r-dev95.netlify.app/

実際の表示:

OGP画像なし:

OGP画像あり:

R.dev

【補足】 Astroへの組み込み

作成したプラグインは、astro.config.mjs に次の通り実装すると使えるようになります。

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構造の変更も自由自在です。既製プラグインをそのまま使うより、一度自分で実装してみることをおすすめします。