AstroのMarkdown・MDXで、Xの埋め込みとOGPリンクカードを表示する

単独のURLをリンクカードやXの投稿へ変換するため、AstroのHTML生成をrehypeで拡張しました。変換対象の選び方、ビルド時のOGP取得、Xの遅延読み込み、失敗時の表示を実装に沿って説明します。

TechHiraoku更新 9 分で読めます

ノートパソコンの横で女性が投稿カードとリンクカードを並べる、淡いブルーグレー背景の黒ペン風イラスト

技術記事に参考資料を載せるとき、リンク先のタイトルや画像が見えると、何を参照しているのか伝わりやすくなります。Xの投稿を引用するなら、本文や投稿者をその場で確認できると読み進めやすくなります。

このブログはAstroとMarkdown・MDXで作っています。ただ、URLを貼っただけでは、どちらも通常のリンクになります。MDXを使えばコンポーネントを書けますが、記事を書くたびに埋め込み用の記法やスクリプトを持ち込むことは避けたいと考えました。

そこで、単独の段落に置いたURLだけを、OGPリンクカードかXの投稿埋め込みへ変換するようにしました。本文中で使うリンクはそのまま残します。

今回は、この変換をAstroのどこへ追加したか、OGPとXで処理を分けた理由、取得できなかったときの表示を説明します。

MDXの記法ではなく、HTMLを作る処理を拡張する

MDXはMarkdownにJSXやJavaScriptの式などを組み合わせられる形式です。リンクを貼れば自動でカードになる、という機能そのものを指しているわけではありません。MDXの公式ドキュメント

今回の要件なら、記事ごとにコンポーネントを呼ぶより、Markdown・MDXをHTMLへ変換するときに、該当するリンクを置き換えるほうが合っていました。同じ書き方を .md でも .mdx でも使えるからです。

Astro 7系では、Markdown処理の標準はSätteriになっています。このブログではrehypeのプラグインを使うため、@astrojs/markdown-remark の unified() を明示的に指定しました。AstroのMarkdownプロセッサー

関係する設定は次のとおりです。既存のサイト設定やシンタックスハイライトの設定は省略しています。

import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import { unified } from '@astrojs/markdown-remark';
import rehypeLinkPreviews from './src/plugins/rehype-link-previews.mjs';

export default defineConfig({
	integrations: [mdx()],
	markdown: {
		processor: unified({ rehypePlugins: [rehypeLinkPreviews] }),
	},
});

rehypeでは、HTMLを表す構文木を扱います。文字列全体を正規表現で書き換える代わりに、「この段落にはリンクだけが入っているか」を、要素の構造から調べられます。

AstroのUnifiedプロセッサーは、こうしたHTML生成用のプラグインを rehypePlugins で受け取ります。UnifiedのAPIリファレンス

変換するのは、独立した段落のリンクだけ

すべてのリンクをカードにすると、文中の補足まで大きな枠へ変わってしまいます。表示を豪華にすることより、書き手が文章とカードを使い分けられることを優先しました。

たとえば、次のURLはカードにします。

https://docs.astro.build/en/guides/markdown-content/

一方、次のようなリンクは本文中に残します。

詳しい設定は[Astroのドキュメント](https://docs.astro.build/)を参照してください。

実装では、構文木のルート直下にある段落だけを調べます。空白のテキストを除き、子要素がリンク1つなら候補にします。以下は条件の抜粋です。

if (node.tagName !== 'p' || parent?.type !== 'root') return;
const children = node.children.filter(
	(child) => !(child.type === 'text' && !child.value.trim()),
);
if (children.length !== 1) return;
const link = children[0];
if (link.type !== 'element' || link.tagName !== 'a') return;

この条件なら、文章の途中のリンク、リスト内のリンク、引用内のリンクは対象になりません。画像を含むリンクなども、続く条件で除外しています。

単独の [記事名](https://example.com/article) も候補になります。通常のリンクにしたい場合は、同じ段落に説明を添えれば大丈夫です。

今回の実装は、自分で管理する記事を対象にしたルールです。リスト内もカードにしたいサイトなら、対象とする親要素の条件を変えることになります。

OGPはビルド時、Xはブラウザーで表示する

URLを選んだ後は、Xの投稿かどうかで処理を分けます。

対象ビルド時に行うこと読者のブラウザーで行うこと
通常の外部ページOGP情報を取得し、カードのHTMLを生成するHTMLを表示し、外部の画像を読み込む
Xの投稿投稿IDと、投稿を開くリンクを出力する画面に近づいたら公式ウィジェットを読み込む

OGPは、リンク先のタイトルや画像などを表すメタデータです。og:title、og:description、og:image などを取得し、ブログ側でカードを組み立てます。Open Graph protocol

Xは、このブログが投稿の見た目を作り直す方式にはしませんでした。投稿IDを公式ウィジェットへ渡し、投稿そのものの表示はX側に任せます。

そのため、OGPカードの本文はJavaScriptがなくても読めますが、Xの埋め込みにはJavaScriptと外部への通信が必要になります。

OGPの取得に失敗しても、記事を読めるようにする

ビルド時に外部ページへ接続する以上、取得できないことはあります。サイトが応答しないこともあれば、HTMLを返さないことや、メタデータが欠けていることもあります。

リンク先が一時的に応答しないだけで、ブログ全体のビルドを失敗させたくはありません。そこで、カードを作れない場合は元のリンクを残す方針にしました。

メタデータの解析にはCheerioを使います。タイトルは og:title がなければHTMLの title、説明は og:description がなければ通常の description を使います。

const meta = (name) =>
	$(`meta[property="${name}"], meta[name="${name}"]`)
		.first().attr('content')?.trim();

const title = meta('og:title') || $('title').first().text().trim();
const description = meta('og:description') || meta('description') || '';

タイトルを取得できれば、画像がなくても文字だけのカードを作れます。画像が後から読み込めなくなった場合は、JavaScriptが有効なら画像部分を隠します。

外部から取得した文字列は、HTMLとしてそのまま挿入せず、構文木のテキストノードとして扱います。メタデータ中にHTMLらしい文字列が入っていても、表示用の文字として渡すためです。

取得の待ち時間や読み込む量を抑えるため、1件ごとに5秒のタイムアウト、取得量1,000,000バイトの上限、追跡するリダイレクトの上限を設けました。

URLはHTTPSに限り、ユーザー名・パスワードをURLへ埋め込んだ形式や、IPアドレスの直書きを除外します。接続前には名前解決の結果も調べ、ローカルやプライベートのアドレスへの接続を避けるようにしています。ただし、これは執筆者が管理するリンク向けの実装です。不特定多数からURLを受け付けるサービスに、そのまま同じ条件を流用することは想定していません。

ビルド時に取得する方式にも、負担は残る

OGPカードのために、閲覧時に動く専用APIは用意していません。静的なHTMLとして配信でき、読者はカードの情報を取りに行く追加のAPIを待たずに済みます。

その代わり、リンク先のタイトルや画像URLが変わっても、再ビルドするまでカードの情報は変わりません。表示用の画像は外部の配信元から直接読み込むため、その画像が後で削除される可能性もあります。

また、外部リンクが増えると、ビルド時の取得にも時間がかかります。プラグイン内では順番に取得し、同じURLへの要求は同一プロセス内の Map で再利用するようにしました。このキャッシュはディスクへ保存せず、次のビルドまで保持する永続キャッシュではありません。

記事数や外部リンクが増えて取得が負担になったら、手動のカード情報や、保存期間を決めたキャッシュを検討する余地があります。今回は、静的ブログの規模で扱いやすい構成を選んです。

Xのウィジェットは、必要になったときに読み込む

Xの投稿URLは、ホスト名とパスから投稿IDを取り出せるものだけを対象にします。x.com だけでなく、従来の twitter.com も扱います。プロフィールなど、投稿以外のURLを埋め込まないようにしました。

ビルド時に出力するのは、投稿IDを持つコンテナーと、Xで投稿を開くリンクです。ブラウザーでは IntersectionObserver でそのコンテナーを監視します。

const observer = new IntersectionObserver((entries) => {
	for (const entry of entries) {
		if (!entry.isIntersecting) continue;
		observer.unobserve(entry.target);
		renderPost(entry.target as HTMLElement);
	}
}, { rootMargin: '300px' });

ビューポートの周囲を300px広げた範囲へ入った時点で読み込みを始めます。記事のずっと下にある投稿のために、ページを開いた直後からウィジェットを読み込む必要がなくなります。

ウィジェットのスクリプト読み込みには、共有するPromiseを1つ使います。同じ記事に複数の投稿があっても、スクリプトの読み込みを重複させません。

スクリプトが利用できるようになったら、createTweet で投稿を表示します。以下は呼び出し部分の抜粋です。

widgets.createTweet(container.dataset.postId!, mount, {
	theme: document.documentElement.dataset.theme === 'dark'
		? 'dark' : 'light',
	lang: 'ja',
	dnt: true,
	align: 'center',
});

このブログにはライト・ダークの切り替えがあるため、テーマ属性の変更を監視し、表示済みの投稿を描き直します。ブログの背景だけが変わり、投稿の色が前のテーマのまま残ることを避けました。

X側の表示仕様、削除・非公開になった投稿、ブラウザーの制限などは、このブログでは制御できません。スクリプトや投稿の表示に失敗した場合も、元から置いてある「Xで投稿を読む」リンクを残します。

表示例を、実装の確認にも使う

ここからは、この仕組みで表示している実際の例です。次の2件は、この記事のMDX本文へ独立した段落として置いたURLから生成しています。

DocsMarkdown in AstroLearn about Astro's built-in support for Markdown.docs.astro.build ↗ MDXDocs | MDXThese docs explain the core concepts of MDX. How the format works, how to add it to your site, how to use MDX files, and how to extend them. Reading through these should give you a good understanding of MDX.mdxjs.com ↗

Xの投稿も、本文にはURLだけを書きます。投稿内容をこの記事で複製しているわけではなく、ブラウザーで公式ウィジェットを読み込んでいます。

一方、文章中のAstroへのリンクは、そのまま文章の一部として残ります。

表示例だけでなく、プラグインのテストでは、メタデータの解析、対象外URL、文中のリンクが残ること、取得に失敗したときのリンク維持を検証しています。外部ページが毎回同じ応答を返すことに依存しないよう、変換のテストではメタデータの取得処理を差し替えられるようにしました。

そのうえで、MarkdownとMDXの両方をビルドし、生成したカードとXの埋め込み用要素を確認しました。ブラウザーでは実際の投稿、カードの画像、ライト・ダークの切り替えを確認しています。テストが成功したことと、外部サービスを含む表示を確認したことは分けて扱いました。

ほかの記事も読む