Markdown から説明文を自動生成する — generateExcerpt 関数の実装と使い方
description を書き忘れた記事でも OGP やタイトルタグが空にならないよう、Markdown 本文から自動で説明文を抽出する generateExcerpt() 関数の実装を紹介します。正規表現でノイズを除去してから文字数制限をかけるシンプルな実装で、日本語にも対応しています。
description 未設定時の問題
フロントマターに description を書き忘れると、以下の場所が空文字になってしまいます。
<meta name="description">— 検索結果のスニペット- OGP の
og:description— SNS シェア時のカード説明文 - Twitter Card の
twitter:description
generateExcerpt() を挟むことで、description ?? 自動生成 というフォールバックが完成します。
generateExcerpt 関数の実装
// src/app/lib/functions.ts
function generateExcerpt(markdownContent: string, maxLength = 120): string {
const text = markdownContent
// frontmatter は呼び出し元で除去済みを想定するが念のため除去
.replace(/^---[\s\S]*?---\n?/, "")
// コードブロック (``` ... ```) を除去
.replace(/```[\s\S]*?```/g, "")
// インラインコード
.replace(/`[^`]*`/g, "")
// 見出し記号
.replace(/^#{1,6}\s+/gm, "")
// 画像
.replace(/!\[.*?\]\(.*?\)/g, "")
// リンク → リンクテキストだけ残す
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
// 強調・太字
.replace(/(\*{1,3}|_{1,3})(.*?)\1/g, "$2")
// 水平線
.replace(/^[-*_]{3,}\s*$/gm, "")
// HTML タグ
.replace(/<[^>]+>/g, "")
// 複数の改行や空白を単一スペースに
.replace(/\s+/g, " ")
.trim();
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + "…";
}
各正規表現が除去する対象
| 正規表現 | 除去対象 | 理由 |
|---|---|---|
/^---[\s\S]*?---\n?/ | frontmatter | YAML がそのまま説明文に入るのを防ぐ |
/\“[\s\S]*?```/g` | コードブロック | コードは説明文に不向き |
/^#{1,6}\s+/gm | 見出し記号 | ## だけ残ると文章が不自然になる |
/!\[.*?\]\(.*?\)/g | 画像 | alt テキストごと除去 |
/\[([^\]]*)\]\([^)]*\)/g | リンク | URL を除去しテキストだけ残す |
/<[^>]+>/g | HTML タグ | <br> などが混入するのを防ぐ |
文字数制限と日本語対応
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + "…";
String.prototype.slice() は UTF-16 コードユニット単位でカウントするため、日本語は 1 文字 = 1 カウントとなり、デフォルトの maxLength = 120 は日本語で 120 文字に相当します。
generateMetadata との連携
// src/app/posts/[slug]/page.tsx
import { generateExcerpt } from "../../lib/functions";
import matter from "gray-matter";
import fs from "fs";
import path from "path";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const filePath = path.join(process.cwd(), "posts", `${params.slug}.md`);
const fileContents = fs.readFileSync(filePath, "utf8");
const { data, content } = matter(fileContents);
// description が未設定なら Markdown 本文から自動生成
const description: string = data.description ?? generateExcerpt(content);
return {
title: `${data.title} | ブログタイトル`,
description,
openGraph: {
type: "article",
description,
},
twitter: {
card: "summary_large_image",
description,
},
};
}
gray-matter の matter() は data(frontmatter)と content(frontmatter を除いた本文)を分けて返します。content をそのまま generateExcerpt() に渡せます。
ハマりやすいポイント
1. コードブロックの中身が説明文に混入する
正規表現の順番が重要で、コードブロック除去(```)をインラインコード除去(`)より先に行う必要があります。
// NG: インラインコードを先に除去するとコードブロックが壊れる
.replace(/`[^`]*`/g, "") // ← 先にやると ``` が壊れる
.replace(/```[\s\S]*?```/g, "") // ← 中身が残る
// OK: コードブロックを先に除去する
.replace(/```[\s\S]*?```/g, "") // ← 先にコードブロックごと除去
.replace(/`[^`]*`/g, "") // ← 残ったインラインコードを除去
2. 記事冒頭がコードブロックで始まる場合
記事の冒頭にコードブロックが来ると、そのブロックが除去された後に続くテキストが先頭になります。意図した説明文が得られない場合は、フロントマターに明示的に description を書くのが確実です。
3. サロゲートペア文字(絵文字など)
slice() は UTF-16 コードユニット単位のため、絵文字を含む場合に境界がずれることがあります。
// サロゲートペア対応版
if (text.length <= maxLength) return text;
return Array.from(text).slice(0, maxLength).join("") + "…";
業務での使いどころ
CMS 移行・インポート時のフォールバック
既存記事を別システムから移行する際に description が未設定のデータが大量にある場合に役立ちます。
const filePath = "posts/some-post.md";
const { data, content } = matter(fs.readFileSync(filePath, "utf8"));
if (!data.description) {
const excerpt = generateExcerpt(content);
const updated = matter.stringify(content, { ...data, description: excerpt });
fs.writeFileSync(filePath, updated);
}
RSS フィード生成との連携
RSS の <description> タグにも同じ関数を流用できます。description ?? generateExcerpt(content) とすれば、フィードの説明文も自動補完されます。
まとめ
generateExcerpt(content)は Markdown 本文からコード・記号・タグを除去し、120 文字以内のテキストを返すdata.description ?? generateExcerpt(content)のフォールバックパターンで、description の書き忘れを自動カバーできる- OGP / Twitter Card / RSS の説明文を一元管理でき、SEO の抜け漏れを防げる
- 記事冒頭をコードブロックにしている場合や絵文字を多用する場合は注意が必要