meta descriptionが「PR」で途切れていた。原因はYAMLの「#」だった

meta descriptionが「PR」で途切れていた。原因はYAMLの「#」だった

本番のHTMLを取ってくると、こう返ってきました。

<meta name="description" content="hibanas-netで実際に使ったcontent branch、PR">

文が「PR」の2文字で終わっています。記事のfrontmatterには、80字の説明文をきちんと書いていました。この欠けを見つけた経緯は、少し遠回りです。探していたのは、まったく別のものでした。

語尾を数えるつもりが、行数が合わなかった

始まりは、もっと地味な疑いでした。毎日AIに書かせている記事のdescription(検索結果やSNSカードに出る説明文)が、同じ語尾ばかりになっていないか。記事は下書き11本を含めて161本あります。既存の品質検査は、descriptionの有無しか見ていません。scripts/audit-frontmatter.mjsを読んで、そう確認しました。

数える前に、まずdescriptionの行を全部抜き出しました。

grep -h "^description:" src/content/posts/*.md | wc -l
ls src/content/posts/*.md | wc -l
171
161

記事は161本、description行は171行。10行多い。frontmatterに2回書いた記事があるのかと疑って、ファイルごとの行数を数えました。

grep -c "^description:" src/content/posts/*.md | grep -v ":1$"
src/content/posts/hermes-agent-memory-personality-skills-layers.md:2
src/content/posts/hermes-agent-plugin-doctor.md:2
src/content/posts/hermes-agent-project-skills-trust.md:2
src/content/posts/hermes-agent-skill-after-task.md:3
src/content/posts/hermes-agent-skill-bundles.md:6

5本とも、本文のコードブロックに載せたSKILL.mdやplugin.yamlの記載例が原因でした。hermes-agent-plugin-doctor.mdで行番号を見ます。

grep -n "^description:" src/content/posts/hermes-agent-plugin-doctor.md | cut -d: -f1
3
95

3行目がfrontmatterで、95行目が本文中の記載例です。grepは両方を区別せず拾います。frontmatterだけを読む道具が要ります。

最初の集計は、この雑なgrepとPythonの正規表現でやりました。description:で始まる最初の1行を、frontmatterの範囲から取る書き方です。長さの順に並べると、2位は162字のhermes-agent-slack-ops-work-ownershipでした。この時点では、ただの長い説明文だと受け取っていました。

書いた長さと、読めた長さが合わない

行を取り違えたことに引っかかって、読み方を変えました。このリポジトリのaudit-frontmatter.mjsは、gray-matterというパッケージでfrontmatterを解釈しています。同じ読み方で、「ファイルに書いてある1行」と「解釈後の値」を突き合わせることにしました。

gray-matterはfrontmatterを読み込み、YAMLとして解釈するnpmパッケージです。YAMLは、設定を「キー: 値」の形で書く形式です。記事の先頭、---に挟まれた部分がこれにあたります。

リポジトリの直下に、次のcheck-cut.mjsを置きました。titleとdescriptionについて、書いた行の値と解釈後の値の長さが違えば失敗にします。

check-cut.mjs 全文
import fs from "node:fs";
import matter from "gray-matter";

const dir = process.argv[2] ?? "src/content/posts";
const KEYS = ["title", "description"];
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
if (files.length === 0) {
  console.error("NG: 指定した場所に .md がありません");
  process.exit(2);
}

let bad = 0;
for (const f of files) {
  const raw = fs.readFileSync(`${dir}/${f}`, "utf8");
  const head = raw.match(/^---\n([\s\S]*?)\n---/);
  if (!head) {
    console.log(`NG ${f}: frontmatter がありません`);
    bad++;
    continue;
  }
  const data = matter(raw).data;
  for (const key of KEYS) {
    const m = head[1].match(new RegExp(`^${key}:[ \\t]*(.*)$`, "m"));
    if (!m) {
      console.log(`NG ${f}: ${key} の行がありません`);
      bad++;
      continue;
    }
    const written = m[1].trim();
    if (/^["']/.test(written)) continue; // 引用符つきは " #" で切れない
    const parsed = String(data[key] ?? "").trim();
    if (written !== parsed) {
      console.log(`NG ${f}: ${key} は${written.length}字書いたが、読めたのは${parsed.length}字「${parsed}」`);
      bad++;
    }
  }
}
console.log(`${files.length} files, ${bad} NG`);
process.exit(bad ? 1 : 0);
node check-cut.mjs src/content/posts
echo "exit=$?"
NG ai-agent-draft-approval-publish-gates.md: description は80字書いたが、読めたのは35字「hibanas-netで実際に使ったcontent branch、PR」
NG hermes-agent-slack-ops-work-ownership.md: title は57字書いたが、読めたのは18字「Hermes AgentをSlack」
NG hermes-agent-slack-ops-work-ownership.md: description は162字書いたが、読めたのは30字「Socket ModeのHermes AgentをSlack」
161 files, 3 NG
exit=1

2本、3項目。切れた場所はどれも、空白と#の手前でした。PR #24、Slack #ops。Slackのチャンネル名やPR番号を書きたくなる気持ちはよく分かります。

YAMLの仕様は、コメントを他の要素から空白で区切るよう定めています。原文は「Comments must be separated from other tokens by white space characters」です。空白の直後に#が来ると、そこから行末までは説明文ではなくコメントとして捨てられます。エラーは出ません。ビルドも通ります。

frontmatterの行で、空白に続けて#を含むものを全記事から探すと、この3行だけでした。

本番では、本当に切れていた

ファイルの解釈と、本番の見え方は別の話です。念のため、公開中の記事のHTMLを取りました。冒頭で見せたmeta descriptionが、その出力です。PRの後ろにあった「#24、Astroのdraftを基に、…」が、丸ごと消えています。

この記事のレイアウト(src/layouts/Base.astro)は、同じdescriptionを<meta name="description">、og:description、twitter:descriptionの3か所に出します。取れた出力で確かめたのはmeta descriptionだけです。ただ、3か所が同じ変数なので、OGとXのカードも同じ文字列だろうと読んでいます。

もう1本のhermes-agent-slack-ops-work-ownershipは下書きのまま、公開していません。公開前に見つかったので被害はありません。ただ、題名まで18字で切れています。そのまま公開していたら、タイトルは「Hermes AgentをSlack」になっていたはずです。

ai-agent-draft-approval-publish-gatesの方は、記事の日付が2026年8月18日です。git log -G '^description:'で見ると、この行を最後に触ったコミットも同じ日でした。それ以降は触っていないので、公開から約3か月、この状態だったことになります。

Googleがmeta descriptionを使うかどうかは、状況しだいです。本文から説明を作ることもあると、公式文書は書いています。長さの上限はなく、端末の幅で切られるとも。今回の欠け方は、その切り詰めとは別物です。Googleへ渡す時点で、文がもう欠けていました。この記事が実際の検索結果にどう出ているかは、確認していません。ここは、まだ分かっていません。

見逃さない検査かを、悪い入力で確かめる

検査が、動いたように見えるだけでは困ります。壊れた入力を意図して作り、きちんと失敗するか確かめました。$TMPDIRの下に、悪い入力を含む5つの入力例を作ります。

入力例を作るコマンドと、hashの中身
mkdir -p "$TMPDIR/cut-samples"/{empty,missing,hash,quoted,nospace,fixed}
printf -- '---\ntitle: sample-a\ndate: 2026-11-13T08:00:00+09:00\n---\n本文\n' > "$TMPDIR/cut-samples/missing/a.md"
printf -- '---\ntitle: sample-b\ndescription: PR #24 を見て直した\ndate: 2026-11-13T08:00:00+09:00\n---\n本文\n' > "$TMPDIR/cut-samples/hash/b.md"
printf -- '---\ntitle: sample-c\ndescription: "PR #24 を見て直した"\ndate: 2026-11-13T08:00:00+09:00\n---\n本文\n' > "$TMPDIR/cut-samples/quoted/c.md"
printf -- '---\ntitle: sample-d\ndescription: PR#24 を見て直した\ndate: 2026-11-13T08:00:00+09:00\n---\n本文\n' > "$TMPDIR/cut-samples/nospace/d.md"
cat "$TMPDIR/cut-samples/hash/b.md"
---
title: sample-b
description: PR #24 を見て直した
date: 2026-11-13T08:00:00+09:00
---
本文

emptyは空のディレクトリ、missingはdescriptionの行がない記事です。hashはPR #24をそのまま書いたもの、quotedは同じ文を二重引用符で囲んだもの、nospaceはPR#24と空白を詰めたものです。

for d in empty missing hash quoted nospace; do
  echo "== $d"
  node check-cut.mjs "$TMPDIR/cut-samples/$d"
  echo "exit=$?"
done
== empty
NG: 指定した場所に .md がありません
exit=2
== missing
NG a.md: description の行がありません
1 files, 1 NG
exit=1
== hash
NG b.md: description は13字書いたが、読めたのは2字「PR」
1 files, 1 NG
exit=1
== quoted
1 files, 0 NG
exit=0
== nospace
1 files, 0 NG
exit=0

悪い入力の3つは、終了コード1か2で止まりました。通ったのは、引用符で囲んだquotedと、空白を詰めたnospaceです。

検査の穴も、先に書いておきます。引用符で囲んだ値は読み飛ばしています。値を何行かにまたいで書く形は、想定していません。この記事のfrontmatterは、どちらも使っていません。

直し方は、引用符で包むだけ

直し方は2通りあります。説明文を二重引用符で囲みます。または、#の前の空白をなくします。PR#24と書くと、#の直前が空白ではなくなるので、コメントとは読まれません。上のnospaceで確かめた通りです。ただ、文の読みやすさを変えずに済むのは前者です。

本物の1本を複製して、引用符で包んでみました。公開中の記事そのものには触れていません。

sed -E '0,/^description: (.*)$/s//description: "\1"/' \
  src/content/posts/ai-agent-draft-approval-publish-gates.md \
  > "$TMPDIR/cut-samples/fixed/ai-agent-draft-approval-publish-gates.md"
grep -n '^description:' "$TMPDIR/cut-samples/fixed/ai-agent-draft-approval-publish-gates.md"
node check-cut.mjs "$TMPDIR/cut-samples/fixed"
3:description: "hibanas-netで実際に使ったcontent branch、PR #24、Astroのdraftを基に、生成・レビュー・公開を混ぜない運用を組み立てます。"
1 files, 0 NG

gray-matterで読み直すと、80字がそのまま取れました。検査の合格だけでは足りないので、読めた長さも別に出して確かめました。

node -e 'const m=require("gray-matter");const fs=require("fs");const d=m(fs.readFileSync(process.argv[1],"utf8")).data.description;console.log(d.length, d)' \
  "$TMPDIR/cut-samples/fixed/ai-agent-draft-approval-publish-gates.md"
80 hibanas-netで実際に使ったcontent branch、PR #24、Astroのdraftを基に、生成・レビュー・公開を混ぜない運用を組み立てます。

今回は直していない

公開済みの記事は、この下書きの中では書き換えません。直すのは2本ですが、1本は公開中です。別の小さなPRにして、レビューを通すのが筋です。同じ検査をCIに入れる案も、別の話になります。audit-frontmatter.mjsがgray-matterを使っているので、そこに足すのがいちばん近道です。

この手の欠けは、人間もAIも書いた直後に見抜けないところです。説明文の見た目は、PR #24と書いた時点で完成しています。切れるのは、ファイルを読む側です。ビルドは通り、記事は公開されました。

語尾の偏りを数える作業は、切れた2本の扱いが決まってから進める方がよいです。壊れた値の語尾を数えても、意味がないからです。

出典

この記事をシェア