📝 この記事は forge.workstyle.tech に掲載した記事の転載です。
この記事では、Astroブログで記事ごとのOGP画像を作り、公開のたびに自動生成する仕組みを組める。記事画像が無い場合の共通画像と、ビルド後の検査も加える。
前提は、Astroでブログを運用していること、記事のslugを取得できること、記事を公開するスクリプトがあること。例では、記事Markdownのfrontmatterにstatus、slug、title、createdがあり、生成画像をサイトのpublic/og/へ同期する。
私は記事画像を生成するスクリプトを作っていた。けれど、公開の流れにはつないでいなかった。2026-10-01にXへ投稿した記事のカードを見て、初めてその抜けに気づいた。
手順1:記事ごとの画像を生成する
まず、OGP画像を作るスクリプトを用意する。例では記事タイトルと日付をHTMLテンプレートへ渡し、Chromeのヘッドレスモードで1200×630のPNGにする。デザインは紺の背景にタイトルを載せる。
次は、指定したogp-build.mjsからの抜粋だ。Vault内の記事を読み、公開済みの記事だけを生成対象にする。既定では、すでに画像がある記事を飛ばす。
const args = process.argv.slice(2);
const all = args.includes("--all");
const only = args.filter((a) => !a.startsWith("--"));
for (const f of readdirSync(BLOG)) {
if (!f.endsWith(".md") || f.startsWith("_")) continue;
const t = readFileSync(join(BLOG, f), "utf8");
const meta = fm(t);
if (meta.status !== "published") continue;
const slug = meta.slug || f.replace(/\.md$/, "");
if (only.length && !only.includes(slug)) continue;
if (!all && !only.length && existsSync(join(OUT, `${slug}.png`))) continue;
targets.push({
slug,
title: meta.title || slug,
date: (meta.published_at || meta.created || "").slice(0, 10),
});
}
画像の生成には、記事タイトルと日付をクエリとしてテンプレートに渡す。ogp-template.htmlでは、タイトルの長さに応じて文字サイズを変え、日付も画像に載せている。テンプレートの寸法は1200×630だ。
このスクリプトでは、画像の元データを記事Markdownと同じVault側に置く。出力先はGenerated/Blog/og/<slug>.png。サイトのpublic/og/へ写す処理は、画像生成とは分けてある。
実行例は次のとおり。パスは自分のリポジトリに合わせて置き換える。
node /path/to/vault/_system/ogp-build.mjs
通常実行では未生成の画像だけが対象になる。全記事を作り直す場合は--all、指定記事だけならslugを渡す。
node /path/to/vault/_system/ogp-build.mjs --all
node /path/to/vault/_system/ogp-build.mjs my-article-slug
生成スクリプトは対象記事をfrontmatterから拾う。したがって記事の公開状態やslugの持ち方が異なる場合は、ここを自分の構成に合わせる。例のスクリプトではstatusがpublishedの記事だけが対象だ。
ここで詰まりやすい点: 画像生成スクリプトを手で一度動かしても、その後に増える記事は自動では対象にならない。私の環境では、画像生成自体はできていたが公開手順に入っていなかった。公開済み93本のうち画像があったのは73本で、9月下旬以降の20本は記事ごとの画像が無かった。
手順2:公開フローの最初に画像生成を入れる
画像が作れるようになったら、記事をサイトへ同期する前に生成スクリプトを実行する。指定のpublish.shでは、公開状態への更新のあと、サイト同期より前に次の処理を入れている。
echo "② OGP 画像を作る(未生成の記事だけ)"
node "$VAULT/_system/ogp-build.mjs" >/dev/null
echo "③ サイトへ同期"
cd "$SITE"
npm run --silent sync
順序が大切だ。記事を公開状態にしてから画像を生成し、その後にサイトへ同期する。これで公開対象の記事が生成処理から見える。同期によって、Vaultの画像がAstroサイトのpublic/og/へコピーされる。
自分の公開処理が記事の公開化、同期、コミットなどを別の順序で行う場合も、記事が公開対象になった後、Astroのビルドより前に画像生成と同期を置く。生成だけして同期を忘れると、ビルド時点でサイトのpublic/og/に画像がない。
ここで詰まりやすい点: 画像が無くてもAstroのページはビルドできる。私のブログでは、記事画像が見つからないとテンプレート付属のblog-placeholder-1.jpgへ進んでいた。そのため公開処理は失敗せず、カードに「Build the web you want」と出るまで抜けが見えなかった。
手順3:記事画像が無い場合の共通画像を決める
記事固有の画像がないとき、Astroの既定画像をそのまま表示すると、未生成に気づきにくい。サイト側で共通画像を用意し、フォールバック先を決めておく。
指定のBaseHead.astroでは、記事のslugからpublic/og/<slug>.pngを探す。ファイルがあれば記事画像を使い、無ければpublic/og/_site.pngを参照する部分の抜粋がこちらだ。
const m = Astro.url.pathname.match(/^\/blog\/([^/]+)\/?$/);
const ogSlug = m ? m[1] : null;
const ogFile = ogSlug
? join(process.cwd(), "public", "og", `${ogSlug}.png`)
: null;
const siteOg = join(process.cwd(), "public", "og", "_site.png");
const ogImage = ogFile && existsSync(ogFile)
? new URL(withBase(`/og/${ogSlug}.png`), Astro.site ?? Astro.url)
: existsSync(siteOg)
? new URL(withBase("/og/_site.png"), Astro.site ?? Astro.url)
: new URL(image.src, Astro.url);
この例では、記事固有の画像が優先される。見つからない場合はサイト共通の画像、共通画像も無ければコンポーネントに渡された画像へ進む。Astroコンポーネントのog:imageとtwitter:imageの両方に、決めたogImageを設定する。
<meta property="og:image" content={ogImage} />
<meta name="twitter:image" content={ogImage} />
共通画像もpublic/og/_site.pngに配置する。私の環境では、記事画像と同じデザインで、サイトの記録を示すタイトルを入れた画像にした。
ここで詰まりやすい点: 共通画像をフォールバックにすると、記事固有画像が無いページもそれらしいカードになる。私の環境でも、Astro既定画像が出ていた20本はページとして公開され、生成漏れ自体はエラーにならなかった。共通画像を用意しただけで完了にせず、次のビルド後検査も加える。
手順4:ビルド後に共通画像へのフォールバックを探す
公開前の生成と同期を済ませても、ビルド結果に記事固有画像が設定されたか確認する。publish.shでは、ビルド後のdist/blog/*/index.htmlを検索する。
missing=$(grep -L '/og/[^"]*\.png' dist/blog/*/index.html 2>/dev/null; grep -l '/og/_site\.png' dist/blog/*/index.html 2>/dev/null)
if [ -n "$missing" ]; then
node "$VAULT/_system/ogp-build.mjs" >/dev/null
npm run --silent sync >/dev/null
npx astro build >/dev/null
still=$(grep -l '/og/_site\.png' dist/blog/*/index.html 2>/dev/null)
[ -n "$still" ] && osascript -e \
'display notification "記事のOGP画像が作れず、サイト共通の画像で公開しました" with title "ブログ"'
fi
このコードは指定ファイルからの抜粋だ。実ファイルではビルド後に記事ページを検索し、記事画像の指定がないページや共通画像を参照するページがあれば、画像生成、同期、再ビルドを行う。それでも共通画像へのフォールバックが残った場合はMacに通知する。
記事一覧ページや記事以外のページを検査対象に含めないよう、検索対象はdist/blog/*/index.htmlに絞っている。記事以外にも同じ画像検査が必要なら、対象パスを自分のサイト構成に合わせて追加する。
シェルの検索は、HTMLに出力されたURLの形に依存する。withBaseの設定やデプロイ先のサブパスによってURLが変わる場合は、検索文字列を生成結果に合わせる。検査が何を見ているか分かるように、実際のビルド後HTMLにあるog:imageを見てから条件を決める。
ここで詰まりやすい点: ビルド後のHTMLが共通画像を指していれば、画像生成または同期が間に合っていない可能性がある。私のブログでは、共通画像が選ばれたページを見つけたら生成と同期をやり直し、再ビルドするようにした。それでも残る場合は通知する。
手順5:公開フロー全体を確認する
設定をつないだら、記事を1本用意して、公開フローを通す。確認するのは、画像ファイルだけではない。公開状態の記事が生成対象になり、同期され、ビルド後のHTMLが記事固有の画像URLを指すところまで見る。
確認の順は次のとおり。
- 公開対象の記事にslugとタイトルがあることを確認する。
- 生成スクリプトを実行し、
Generated/Blog/og/<slug>.pngができることを確認する。 - 同期後に、サイトの
public/og/<slug>.pngが存在することを確認する。 - Astroをビルドし、
dist/blog/<slug>/index.html内のog:imageとtwitter:imageが記事画像を指すことを確認する。 - 記事画像のファイルが無い状態では、
public/og/_site.pngへのフォールバックが出ることを確認する。 - ビルド後検査が共通画像を見つけたとき、再生成と再ビルドを行い、それでも残れば通知することを確認する。
投稿済みのXカードを使って表示だけを確かめると、サイト側の修正が反映されるまで待つことがある。Xはカード情報をしばらくキャッシュし、手動で更新させる公式の手段としてCard Validatorを使える状態ではない。確認では、まずビルド後HTMLと画像URLを見る。私の環境ではデプロイ直後に画像URLが一瞬404を返し、数秒後に200になった。
画像URLが200になっても、すでに投稿済みのカードがすぐ変わるとは限らない。サイトのビルド結果と、X上のキャッシュ表示は別々に確認する。
まとめ
記事ごとのOGP画像を作るだけでは、新しい記事に自動で適用されるとは限らない。生成、同期、ビルドの順で公開フローにつなぐ。
今回の構成では、記事画像があればそれを使い、無ければサイト共通画像へ進む。ビルド後に共通画像を指す記事ページが残っていないか検査し、見つけた場合は作り直して再ビルドする。
確認するときは、生成されたPNG、サイト側へ同期されたファイル、ビルド後のog:imageを順に見る。手動で一度動いた処理が、新しく増える記事にも実行されているか。その点まで公開フローで確かめられるようにしておく。