連載「Astro × microCMS でブログを作る」第4回

Astro × microCMS で OGP・サイトマップ・画像の WebP 化を設定する

この回でやること

連載「Astro × microCMS でブログを作る」の第4回です。記事を検索エンジンや SNS に正しく伝えるための設定と、画像を軽くする設定をまとめて入れます。

  • canonical・OGP・X(Twitter)カードのメタタグを共通レイアウトに入れる
  • meta description を microCMS で入力できるようにし、空なら本文の冒頭から自動で作る
  • @astrojs/sitemap でサイトマップを生成し、robots.txt から案内する
  • microCMS の画像API で、画像を WebP に変換・リサイズする
  • 本文の画像に srcset と遅延読み込みを付ける
  • 広告を含む記事に「PR表記」を出すフィールドを追加する

前回(第3回:コードブロックのシンタックスハイライト)までで、記事一覧・記事詳細・ページ送りと、コードの色分けができています。この回のコードは、ブランチ 04-seo-image の時点のものです。

なお、astro.config.mjs の site(https://cyber-blow.com)と trailingSlash: 'always' は、第1回で設定済みです。この回の canonical とサイトマップは、どちらもこの2つを前提にしています。

canonical と OGP のメタタグを共通レイアウトに入れる

メタタグは全ページで使うので、共通レイアウト(BaseLayout.astro)にまとめます。ページごとに変わる値は Props で受け取ります。

src/layouts/BaseLayout.astro
interface Props {
	/** 未指定ならサイト名のみ */
	title?: string;
	description?: string;
	/** OGP画像の絶対URL */
	image?: string;
	type?: 'website' | 'article';
	publishedTime?: string;
	modifiedTime?: string;
}

const {
	title,
	description = SITE_DESCRIPTION,
	image,
	type = 'website',
	publishedTime,
	modifiedTime,
} = Astro.props;

const pageTitle = title ? `${title} | ${SITE_NAME}` : SITE_NAME;
const canonical = new URL(Astro.url.pathname, Astro.site);

<head> の中はこうなります。

src/layouts/BaseLayout.astro
<title>{pageTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />

<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:type" content={type} />
<meta property="og:title" content={title ?? SITE_NAME} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content="ja_JP" />
{image && <meta property="og:image" content={image} />}
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />

ポイントは3つです。

  • canonical は new URL(Astro.url.pathname, Astro.site) で作っています。ドメイン部分は常に設定ファイルの site になるので、Cloudflare Pages のプレビュー用 URL(*.pages.dev)で開いても、canonical は本番の cyber-blow.com を指します。パスだけを使うので、クエリ文字列も付きません。
  • og:image は画像があるときだけ出します。X のカードも、画像があれば大きい画像のカード(summary_large_image)、なければ小さいカード(summary)に切り替えます。
  • 記事ページでは og:type を article にし、公開日時と更新日時を article:published_time・article:modified_time で出します。

参考:The Open Graph protocol

記事詳細ページからは、次のように値を渡します。

src/pages/blog/[id].astro
<BaseLayout
	title={blog.title}
	description={description}
	image={blog.eyecatch && imageUrl(blog.eyecatch.url, { width: 1200, height: 630, format: 'jpg' })}
	type="article"
	publishedTime={publishedAt}
	modifiedTime={revisedAt}
>

OGP 画像には、記事のアイキャッチを 1200×630 の JPEG に変換したものを使います。変換に使っている imageUrl は、後半の「microCMS の画像API」で説明します。

meta description を入力できるようにする

検索結果に表示される説明文(meta description)は、記事ごとに書けるようにします。microCMS の blogs API に、次の2つのフィールドを追加しました。

フィールドID

表示名

種類

備考

description

説明文

テキストエリア

meta description。空なら本文の冒頭から自動生成

pr

PR表記

真偽値

広告・アフィリエイトを含む記事は ON

型にも追加します。

src/lib/microcms.ts
export type Blog = MicroCMSListContent & {
	title: string;
	/** リッチエディタのHTML */
	content: string;
	/** meta description 用。未入力なら本文の冒頭から自動生成する */
	description?: string;
	eyecatch?: MicroCMSImage;
	category?: Category | null;
	/** 広告・アフィリエイトを含む記事なら true(PR表記を出す) */
	pr?: boolean;
};

記事一覧では、転送量を抑えるために fields で取得するフィールドを絞っています(第2回)。新しく足したフィールドは、ここにも忘れずに追加します。

src/lib/microcms.ts
const SUMMARY_FIELDS = [
	'id',
	'title',
	'description',
	'eyecatch',
	'category',
	'pr',
	// …省略(createdAt などの日時)
];

説明文が空のときは、本文の冒頭120文字を使います。コードブロックの中身が説明文になると読みにくいので、<pre> は取り除いてから文字を取り出します。

src/lib/richtext.ts
/** 本文HTMLから meta description 用の抜粋を作る(コードブロックは除外) */
export function toExcerpt(html: string, length = 120): string {
	const $ = cheerio.load(html, null, false);
	$('pre').remove();
	const text = $.text().replace(/\s+/g, ' ').trim();
	return text.length > length ? `${text.slice(0, length)}…` : text;
}
src/pages/blog/[id].astro
const description = blog.description || toExcerpt(blog.content);

?? ではなく || にしているので、microCMS で一度入力してから消した場合(空文字)も、本文からの自動生成に切り替わります。

サイトマップを生成する

サイトマップは、Astro 公式の @astrojs/sitemap で生成します。パッケージをインストールして、設定ファイルの integrations に追加します。

astro.config.mjs
import { defineConfig, envField } from 'astro/config';
import sitemap from '@astrojs/sitemap';

// https://astro.build/config
export default defineConfig({
	site: 'https://cyber-blow.com',
	// canonical・サイトマップ・内部リンクのURL表記を「/」終わりに統一する
	trailingSlash: 'always',
	integrations: [sitemap()],
	// …省略(env の設定)
});

@astrojs/sitemap はサイトの URL を知る必要があるので、site の設定が必須です。ビルドすると、出力先に sitemap-index.xml と sitemap-0.xml が作られます。trailingSlash: 'always' にしているので、サイトマップに載る URL も canonical と同じ「/」終わりにそろいます。

検索エンジンにサイトマップの場所を伝えるため、robots.txt にも書いておきます。

public/robots.txt
User-agent: *
Allow: /

Sitemap: https://cyber-blow.com/sitemap-index.xml

あわせて、共通レイアウトの <head> にも <link rel="sitemap"> を入れました。

src/layouts/BaseLayout.astro
<link rel="sitemap" href="/sitemap-index.xml" />

参考:@astrojs/sitemap | Astro Docs

microCMS の画像API で WebP 化・リサイズする

microCMS にアップロードした画像は、URL にクエリパラメータを付けるだけで、形式やサイズを変えた画像を受け取れます(画像処理には imgix の Rendering API が使われています)。

パラメータ

意味

fm

出力形式(webp・jpg・png など)

w / h

幅・高さ(ピクセル)

fit=crop

w×h いっぱいに拡大・縮小し、はみ出した部分を切り落とす(w と h の両方が必要)

fit=max

縦横比を保ったまま指定サイズに収める。元画像のほうが小さければ拡大しない

毎回 URL を組み立てるのは面倒なので、関数にしました。

src/lib/microcms.ts
type ImageOptions = {
	width: number;
	/** 指定すると width × height にトリミングする */
	height?: number;
	format?: 'webp' | 'jpg' | 'png';
};

/**
 * microCMS の画像API(imgix互換)でフォーマット・サイズを指定したURLを返す。
 * height 未指定時は fit=max なので、元画像より大きく引き伸ばされることはない。
 */
export function imageUrl(src: string, { width, height, format = 'webp' }: ImageOptions): string {
	const url = new URL(src);
	url.searchParams.set('fm', format);
	url.searchParams.set('w', String(width));
	if (height) {
		url.searchParams.set('h', String(height));
		url.searchParams.set('fit', 'crop');
	} else {
		url.searchParams.set('fit', 'max');
	}
	return url.toString();
}

形式は指定しなければ WebP です。高さを指定したときだけ、その縦横比に切り抜きます。

サイト内では、次のように使い分けています。

使う場所

指定

記事一覧のカード

640×360 に切り抜き、WebP

記事詳細のアイキャッチ

幅1520まで、WebP

OGP 画像

1200×630 に切り抜き、JPEG

本文中の画像

幅800と1600の2種類、WebP(次の節)

一覧のカードの画像枠は、CSS で 16:9 に固定しています(aspect-ratio: 16 / 9; object-fit: cover)。そこで、画像API で 640×360(16:9)に切り抜いてから読み込み、<img> の width・height もその値にしました。

src/components/ArticleCard.astro
<img
	class="card-image"
	src={imageUrl(post.eyecatch.url, { width: 640, height: 360 })}
	alt=""
	width="640"
	height="360"
	loading="lazy"
	decoding="async"
/>

この変更までは、元画像をそのまま読み込み、ブラウザ側の CSS で 16:9 に切り抜いていました。先に画像API で切り抜いておけば、表示しない部分までダウンロードせずに済みます。

参考:画像API | microCMS ドキュメント、フォーマット | microCMS ドキュメント、Resize Fit Mode | imgix Docs

本文の画像に srcset と遅延読み込みを付ける

アイキャッチやカードの画像は Astro のテンプレートで書いていますが、本文中の画像は microCMS のリッチエディタが出す HTML の中にあります。そこで、第3回でコードブロックを置き換えたのと同じ renderRichText の中で、cheerio を使って <img> を書き換えます。

src/lib/richtext.ts
const MICROCMS_IMAGE_HOST = 'images.microcms-assets.io';

// …省略(コードブロックのハイライト)

	$('img').each((_, el) => {
		const $img = $(el);
		$img.attr({ loading: 'lazy', decoding: 'async' });

		const src = $img.attr('src');
		if (!src || !isOptimizableImage(src)) return;
		$img.attr({
			src: imageUrl(src, { width: 800 }),
			srcset: `${imageUrl(src, { width: 800 })} 800w, ${imageUrl(src, { width: 1600 })} 1600w`,
			sizes: '(min-width: 800px) 760px, 100vw',
		});
	});
src/lib/richtext.ts
// GIF(アニメーションが消える)と SVG は変換しない
function isOptimizableImage(src: string): boolean {
	const url = URL.parse(src);
	return url?.hostname === MICROCMS_IMAGE_HOST && !/\.(gif|svg)$/i.test(url.pathname);
}

やっていることは次のとおりです。

  • すべての画像に loading="lazy" と decoding="async" を付け、画面に入るまで読み込まないようにする
  • microCMS の画像(images.microcms-assets.io)だけを WebP に変換し、幅800と1600の2種類を srcset で渡す。高解像度の画面では、ブラウザが1600のほうを選べる
  • GIF は変換するとアニメーションが消えるので変換しない。SVG も変換の対象から外す
  • 外部サイトの画像は、URL を書き換えずにそのまま使う

sizes の 760px は、記事の本文エリアの最大幅(CSS の max-width: 760px)に合わせた値です。

一方、記事詳細のアイキャッチは画面の最初に見える位置にあるので、遅延読み込みにせず fetchpriority="high" を付けて優先して読み込ませています。

src/pages/blog/[id].astro
<img
	class="post-eyecatch"
	src={imageUrl(blog.eyecatch.url, { width: 1520 })}
	alt=""
	width={blog.eyecatch.width}
	height={blog.eyecatch.height}
	fetchpriority="high"
/>

広告を含む記事に PR 表記を出す

先ほど追加した pr フィールドを ON にすると、記事タイトルの下に表記が出るようにしました。

src/pages/blog/[id].astro
{blog.pr && <p class="pr-notice">本記事にはプロモーション(広告)が含まれています。</p>}

アフィリエイトや広告を含む記事に、なぜこの表記が必要なのか(ステマ規制)は、連載「技術ブログの収益化準備」の第1回で詳しく書いています。

注意点:microCMS の fields は綴りを間違えてもエラーにならない

この回では SUMMARY_FIELDS に description と pr を追加しました。ここで注意したいのが、microCMS は fields・orders・filters に存在しないフィールド名を指定してもエラーにならないことです。

たとえば fields に 'descripton' と書き間違えても、ビルドは普通に通ります。そのフィールドが返ってこないだけなので、一覧ページの説明文が出ない原因に気づきにくくなります。

このサイトでは、microCMS に API を作ったあと、各 API にダミーのコンテンツを1件ずつ公開して、API が返す JSON でフィールド名と型がコードと合っているかを確認しました。画像API についても、WebP(記事・カード用)と 1200×630 の JPEG(OGP 用)が実際に返ってくることを、このときに確かめています。

まとめ

  • canonical・OGP・X カードのメタタグは共通レイアウトにまとめ、ページごとの値は Props で渡す
  • canonical を Astro.site から作ると、プレビュー用 URL で開いても本番のドメインを指す
  • meta description は入力欄を用意し、空なら本文の冒頭(コードブロック以外)から自動で作る
  • サイトマップは @astrojs/sitemap と site の設定だけで生成でき、robots.txt と <link rel="sitemap"> で場所を伝える
  • 画像は microCMS の画像API で WebP 化・リサイズし、本文の画像は cheerio で srcset と遅延読み込みを付ける

次回は、記事を「連載」としてまとめ、第N回の表示や目次ページを作ります(第5回:連載機能)。