0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Last updated at Posted at 2026-06-04

前回、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 に次の通り実装すると使えるようになります。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?