はじめに
Astroで技術ブログを作ると、Markdownを書いてpushするだけで記事が公開できて快適です。ただ、しばらく運用していると次のようなことが起きがちです。
- ある記事だけ
descriptionを書き忘れていて、Xに貼るとサイト全体の説明文が出る - OG画像を指定したつもりがパスを間違えていて、カードが画像なしになる
-
og:imageが/images/foo.pngのような相対パスのまま出力されていて、クローラーが取得できない
どれも防げるミスですが、毎回気をつけ続けるのは無理があります。そこでこの記事では、書き忘れたらビルドが落ちる仕組みをAstroで作ります。
- Content Collectionsのスキーマで、OGに必要な項目を必須にする
- 共通の
<head>コンポーネントで、絶対URLのOGタグを組み立てる - ビルド後の
dist/をスキャンして、実際に出力されたタグを検査する
Astro 5系を想定していますが、考え方は4系でも同じです。
1. スキーマで「必須」にする
まずastro.config.mjsでsiteを設定します。これがないと絶対URLが作れません。
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://blog.example.com",
});
次にコレクションのスキーマを定義します。ポイントは、descriptionの長さと、OG画像の代替テキストまでスキーマで縛ることです。
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: ({ image }) =>
z.object({
title: z.string().min(1).max(70),
description: z
.string()
.min(30, "descriptionが短すぎます(カードで内容が伝わりません)")
.max(160, "descriptionが長すぎます(途中で切られます)"),
pubDate: z.coerce.date(),
ogImage: image().optional(),
ogImageAlt: z.string().max(120).optional(),
draft: z.boolean().default(false),
}).refine((d) => !d.ogImage || d.ogImageAlt, {
message: "ogImageを指定したらogImageAltも書いてください",
path: ["ogImageAlt"],
}),
});
export const collections = { blog };
image()ヘルパーを使うと、フロントマターに書いた画像パスが実在するかどうかもビルド時に検証されます。パスを間違えればその場でエラーになるので、「画像なしカード」の大半はここで防げます。
文字数の上限はあくまで目安です。プラットフォームによって表示幅が違い、日本語は全角なので英語より早く切れることがあります。大事なのは「空」と「極端に長い」をビルドで弾くことです。
2. 共通の<head>コンポーネント
OGタグは各ページで個別に書かず、1つのコンポーネントに集約します。
---
// src/components/SeoHead.astro
import { getImage } from "astro:assets";
import type { ImageMetadata } from "astro";
import defaultOg from "../assets/og-default.png";
interface Props {
title: string;
description: string;
image?: ImageMetadata;
imageAlt?: string;
type?: "website" | "article";
}
const { title, description, image, imageAlt, type = "website" } = Astro.props;
if (!Astro.site) throw new Error("astro.config.mjs に site を設定してください");
const canonical = new URL(Astro.url.pathname, Astro.site).href;
// OG画像はPNG/JPEGで、1200x630相当に変換して出力する
const og = await getImage({ src: image ?? defaultOg, width: 1200, height: 630, format: "png" });
const ogImageUrl = new URL(og.src, Astro.site).href;
const alt = imageAlt ?? title;
---
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content={type} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={alt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImageUrl} />
<meta name="twitter:image:alt" content={alt} />
ここでのこだわりは3つです。
-
new URL(..., Astro.site)で必ず絶対URLにする。og.srcは/_astro/og.xxxx.pngのようなルート相対パスなので、そのまま出すとクローラーが解決できないことがあります。 -
format: "png"を明示する。 Astroの画像最適化はデフォルトでWebPを出力します。WebPに対応していないクローラーやアプリもまだあるので、OG画像はPNGかJPEGにしておくのが無難です。 -
canonicalとog:urlを同じ値にする。 シェアされたURLとカードの情報がずれる原因を減らせます。
元のOG画像は最初から1200×630で作っておくと、変換時の変形やトリミングを避けられます。
あとは記事ページの<head>で<SeoHead title={post.data.title} description={post.data.description} image={post.data.ogImage} imageAlt={post.data.ogImageAlt} type="article" />のように呼び出すだけです。固定ページも含め、すべてのページがこのコンポーネントを通るようにしておくのがコツです。
3. ビルド後のdist/を検査する
スキーマはフロントマターを守ってくれますが、最終的に出力されたHTMLを保証するものではありません。レイアウトを改修したときにSeoHeadを外してしまった、固定ページだけ別レイアウトだった、ということは普通に起こります。
そこで、ビルド後のHTMLを直接検査します。依存を増やさないよう、Node標準APIと正規表現だけの簡易版です(属性がproperty→contentの順で出力される前提)。
// scripts/check-og.mjs(Node 20以降)
import { readdir, readFile } from "node:fs/promises";
const REQUIRED = ["og:title", "og:description", "og:url", "og:image", "og:image:alt", "twitter:card"];
const meta = (html, key) =>
html.match(new RegExp(`<meta[^>]*(?:property|name)="${key}"[^>]*content="([^"]*)"`, "i"))?.[1];
let errors = 0;
const files = (await readdir("dist", { recursive: true })).filter((f) => f.endsWith(".html"));
for (const f of files) {
const html = await readFile(`dist/${f}`, "utf8");
if (/<meta[^>]*name="robots"[^>]*noindex/i.test(html)) continue; // 404などは除外
for (const key of REQUIRED) {
const v = meta(html, key);
if (!v?.trim()) { console.error(`✗ ${f}: ${key} がありません`); errors++; }
else if ((key === "og:image" || key === "og:url") && !v.startsWith("https://")) {
console.error(`✗ ${f}: ${key} が絶対URLではありません (${v})`); errors++;
}
}
}
if (errors) { console.error(`${errors}件の問題があります`); process.exit(1); }
console.log("OGタグのチェックOK");
package.jsonでビルドに組み込みます。
{
"scripts": {
"build": "astro build && node scripts/check-og.mjs"
}
}
これで、CIでもローカルでも、タグが欠けたページがあればnpm run build自体が失敗します。
4. 最後は「外から」確認する
ビルド時のチェックはあくまで手元のHTMLが対象です。公開後に、CDNのキャッシュ、リダイレクト、WAFのボット対策などで、クローラーに届く内容が変わることがあります。
特にXのカードはtwitter:cardの値によって大きい画像になるか小さいサムネイルになるかが変わるので、私は公開後にOG ScopeのツイッターカードチェッカーにURLを貼って、どのカード形式が要求されているか、画像が取得できているかを確認しています。ビルドで防げるものはビルドで、外からしか分からないものは外から、と役割分担すると安心です。
まとめ
-
siteを設定し、OGタグは1つのコンポーネントで絶対URLとして組み立てる - Content Collectionsのスキーマで
descriptionやOG画像の代替テキストを必須にする -
image()ヘルパーで画像パスの実在をビルド時に検証する - OG画像はWebPではなくPNG/JPEGで出力する
- ビルド後の
dist/を検査して、欠けたページがあればビルドを落とす
「気をつける」をやめて「仕組みにする」と、記事を書くことだけに集中できるようになります。
Abid Ali:パキスタンのWeb開発者。リンクプレビュー確認ツールOG Scopeなど、小さな無料ツールを作っています。
