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

/**
 * Markdown 本文から description 用の抜粋テキストを生成する。
 * frontmatter の description が未設定のときに使用する。
 */
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?/frontmatterYAML がそのまま説明文に入るのを防ぐ
/\``[\s\S]*?```/g`コードブロックコードは説明文に不向き
/\[^`]*`/g`インラインコード同上
/^#{1,6}\s+/gm見出し記号 (## など)## だけ残ると文章が不自然になる
/!\[.*?\]\(.*?\)/g画像alt テキストごと除去
/\[([^\]]*)\]\([^)]*\)/gリンクURL を除去しテキストだけ残す
/(\*{1,3}|_{1,3})(.*?)\1/g強調・太字記号を除去しテキストだけ残す
/^[-*_]{3,}\s*$/gm水平線--- が説明文に入るのを防ぐ
/<[^>]+>/gHTML タグ<br> などが混入するのを防ぐ
/\s+/g連続する空白・改行単一スペースに正規化

文字数制限と日本語対応

if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + "…";

String.prototype.slice() は UTF-16 コードユニット単位でカウントするため、日本語(BMP 内の文字)は 1 文字 = 1 カウントとなり、デフォルトの maxLength = 120 は日本語で 120 文字に相当する。 英語の場合は単語の途中で切れる可能性があるが、description 用途では許容範囲内。

generateMetadata との連携

実際の使用箇所は記事個別ページの 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-mattermatter()data(frontmatter)と content(frontmatter を除いた本文)を分けて返す。 content をそのまま generateExcerpt() に渡せるため、関数内の frontmatter 除去の正規表現は二重の保険として機能する。

ハマりやすいポイント

1. コードブロックの中身が説明文に混入する

正規表現の順番が重要で、コードブロック除去(```)をインラインコード除去(`)より先に行う必要がある。 逆にすると、コードブロックの開始・終了の ``` が 3 つのインラインコードとして解釈され、中身が残ってしまう。

// NG: インラインコードを先に除去するとコードブロックが壊れる
.replace(/`[^`]*`/g, "")        // ← 先にやると ``` が壊れる
.replace(/```[\s\S]*?```/g, "") // ← 中身が残る

// OK: コードブロックを先に除去する
.replace(/```[\s\S]*?```/g, "") // ← 先にコードブロックごと除去
.replace(/`[^`]*`/g, "")        // ← 残ったインラインコードを除去

2. 記事冒頭がコードブロックで始まる場合

記事の冒頭にコードブロックが来ると、そのブロックが除去された後に続くテキストが先頭になる。 意図した説明文が得られない場合は、フロントマターに明示的に description を書くのが確実。

3. サロゲートペア文字(絵文字など)

slice() は UTF-16 コードユニット単位のため、絵文字(サロゲートペア)を含む場合に境界がずれて文字化けすることがある。 技術ブログの本文に絵文字が多用されるケースは少ないが、気になる場合は Array.from(text).slice(0, maxLength).join("") を使うと安全。

// サロゲートペア対応版
if (text.length <= maxLength) return text;
return Array.from(text).slice(0, maxLength).join("") + "…";

業務での使いどころ

CMS 移行・インポート時のフォールバック

既存記事を別システムから移行する際、description が未設定のデータが大量にある場合に役立つ。 移行スクリプト内で generateExcerpt() を呼び出し、フロントマターに自動補完してからインポートすることもできる。

// 移行スクリプトの例
import { generateExcerpt } from "./functions";
import matter from "gray-matter";
import fs from "fs";

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 の抜け漏れを防げる
  • 記事冒頭をコードブロックにしている場合や絵文字を多用する場合は注意が必要