Astro - rehypeプラグインを作って単体URLのリンクカード化を実現する
- astro
- remark
- rehype
- markdown
- github
前回、remarkプラグインでGFMのアラート記法を実現しました。

Astro上でremarkプラグインを作ってGFMのアラート記法を実現しました。
r-dev95.netlify.app
今回は、rehypeプラグインで単体URLのリンクカード化を実現していきます。
ここでいう単体URLのリンクカード化とは、外部リンクのURLだけ貼ると、タイトルや説明、サムネイルを表示するリッチなリンクカードに変換してくれる機能のことです。
https://example.comZennやQiitaのようなプラットフォームでよく見かけるあれです。
上述のような機能は、Astroにはデフォルトではサポートされていません。
前回も言及しましたが、
既製のプラグインを使う方法もありますが、仕組みさえわかればそれほど難しい実装ではありません。自分で作ればHTML構造、クラス名、アイコン、タイトルなど自由にカスタマイズ可能です。
remark だけでなく、rehype でも同様のことが言えると思います。
この記事では、rehypeプラグインをゼロから自作する方法を解説します。
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);};実装したプラグイン
実装したプラグインと実際に使った場合の表示は次の通りです。
実装
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画像なし:
example.com
OGP画像あり:

R.devの個人サイトです。ブログ記事と作品URLを載せていきます。
r-dev95.netlify.app
【補足】 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構造の変更も自由自在です。既製プラグインをそのまま使うより、一度自分で実装してみることをおすすめします。