きっかけは、社内サイトの記事一覧を眺めていて「この記事、いつの情報だろう」と自分で思ったことでした。
4月に公開した記事に、月額のインフラ費が書いてある。数字は公開後に一度直している記憶がある。でもページに出ている日付は公開日の1つだけで、直したことはどこにも表れていない。読者から見れば、4月時点の数字が4月時点のまま置かれているのか、その後に見直された数字なのか、区別がつかないんですよね。
データベースの設計なら、この手の話は迷いません。テーブルに created_at と updated_at を持たせて、更新時に updated_at を触る。監査カラムと呼ばれるやつです。私自身、これを持たないテーブルで障害調査をして痛い目を見た経験が何度かあるので、新しいテーブルを切るときは反射的に付けます。
ところが、自社サイトの記事は「テーブル」だと思っていませんでした。MDXファイルがフォルダに並んでいるだけで、スキーマは src/content.config.ts の Zod 定義です。改めて見ると、記事コレクションのスキーマは publishedAt: z.coerce.date() を持つだけで、updatedAt に相当する項目がありませんでした。
面白いのは、同じファイルの中に updatedAt: z.coerce.date().optional() を持つコレクションが1つだけあったことです。WordPressのトラブル対処をまとめた恒久記事のコレクションで、設計した当時の私たちは「この記事群は加筆していくから更新日が要る」と判断していました。テンプレート側も対応していて、値が入っていれば日付の横に「更新」と表示されます。
つまり、更新日をどう扱うかは、半年前に一度ちゃんと考えていた。考えた結果が、9記事のうち0記事に値が入っている、という状態でした。
最初のアプローチ: gitの最終コミット日を更新日にする
「人が書かないなら機械に出させればいい」というのが最初の発想でした。記事はgitで管理しているので、ファイルの最終コミット日を取れば更新日になる。サイトマップの lastmod にもそのまま流せる。
# 最初のアプローチ。初回以降にコミットがあれば、最終コミット日を更新日とみなす
for f in src/content/news/*.mdx; do
n=$(git log --follow --oneline -- "$f" | wc -l) # そのファイルに触れたコミット数
last=$(git log -1 --format=%cs -- "$f")
[ "$n" -gt 1 ] && echo "$f updated=$last ($n commits)"
done | wc -l
# => 73
81記事中73記事が「更新済み」になりました。9割です。
これを見た瞬間に、このままサイトマップへ流すのはやめました。記事の9割を更新済みとして出すのは、事実としても、検索エンジンへの信号としても嘘に近い。クローラ側は lastmod が実際のページ更新と一貫して合っている場合にだけ使う、という立場を公開していて、合っていない値を出し続けると信号そのものを無視されるようになります。全部を新しく見せようとすると、全部が信用されなくなる。
ではこの73件は何だったのか。コミットメッセージを眺めると、大半が「サムネイル追加」でした。
このサイトでは記事の公開とサムネイルの付与を別の作業に分けています。記事のPRがマージされた後、別のPRで thumbnail: の1行をフロントマターに足す。これが73記事に対して行われていて、本文には1文字も触れていない。読者にとっての「更新」ではないのに、gitから見れば立派な変更です。
改善後のアプローチ: 変更を層に分けて数え直す
そこで、コミットの有無ではなく「何が変わったか」で数え直すことにしました。全履歴を持ってきて(浅いcloneだったので git fetch --unshallow が必要でした)、各記事の連続するコミット間で内容を比較します。比較の前に、どこまで「見た目」を落とすかを段階に分けました。
# 連続するコミット間で、正規化レベルごとに本文が変わったかを数える
import re, subprocess, glob
def sh(*a):
return subprocess.run(a, capture_output=True, encoding='utf-8').stdout
def normalize(body, level):
b = re.sub(r'\{/\*.*?\*/\}', '', body, flags=re.S) # 追跡用コメント
if level >= 1:
b = re.sub(r'\sstyle="[^"]*"', '', b) # インラインの見た目
if level >= 2:
b = re.sub(r'(src|href)="[^"]*"', '', b) # 画像パス・リンク先
if level >= 3:
b = re.sub(r'<[^>]+>', '', b) # タグを全部落としてテキストだけにする
b = re.sub(r'^\s*\|?[\s:|-]+\|?\s*$', '', b, flags=re.M)
b = b.replace('|', ' ')
return re.sub(r'\s+', ' ', b).strip()
def split_frontmatter(src):
m = re.match(r'---\r?\n(.*?)\r?\n---\r?\n?(.*)', src.lstrip('\ufeff'), re.S)
return m.group(1), m.group(2)
changed = {lv: set() for lv in range(4)}
for path in glob.glob('src/content/news/*.mdx'):
path = path.replace('\\', '/') # Windows で git show に渡すため
commits = sh('git', 'log', '--follow', '--format=%H', '--', path).split()[::-1]
prev = None
for h in commits:
_, body = split_frontmatter(sh('git', 'show', f'{h}:{path}'))
if prev is not None:
for lv in range(4):
if normalize(body, lv) != normalize(prev, lv):
changed[lv].add((path, h))
prev = body
for lv in range(4):
files = {p for p, _ in changed[lv]}
print(f'level {lv}: {len(changed[lv])} events / {len(files)} files')
結果を段階ごとに並べると、こうなりました。
| 「更新」の数え方 | 変更イベント | 記事数(81本中) | 主に何が落ちたか |
|---|---|---|---|
| 初回以降にコミットがある | 139 | 73 | (基準) |
| フロントマターだけの変更を除く | 60 | 30 | thumbnail 追記 73件、author 修正 5件、status 3件 など79件 |
| さらに style 属性を無視 | 52 | 30 | ブランドカラー刷新で色コードだけ変わった7記事(146行)など8件 |
| さらにタグと表の記法を落としてテキストだけ比較 | 27 | 20 | Markdown の表を HTML の table 要素に書き換えた作業、画像の差し替え |
73が20になりました。残った27件の中身を1件ずつ見ると、事実の修正(インフラ費を「約3〜5万円」から「約1.5〜3万円」へ)、レビュー指摘による表現の書き換え、記事末尾への案内カード追加が6記事まとめて1コミット、検索流入を見てタイトルと見出しを書き直したもの、といった顔ぶれです。これは読者に「更新した」と伝えていい変更だと思います。
正直なところ、ここまで数えてから気付いたことが2つあります。
想定外だったこと
1つ目は、デザイン変更が記事本文のコミットになっていたことです。
6月にサイトのブランドカラーを赤系から青系に刷新したとき、本文に表をHTMLで直書きしていた記事が7本あり、そのセル1つ1つに style="border:1px solid rgba(184,32,72,.25); background-color:#FAF3F6" のような色指定が入っていました。デザインを変えるには本文を書き換えるしかなく、その1コミットで7記事、146行が変わっています。中身は1文字も変わっていません。
当然、最初のアプローチではこの7記事が全部「更新済み」になります。本文比較でも style 属性を無視する処理を入れるまでは残りました。今思えば、これは「見た目の情報をデータに埋めた」時点で決まっていた話で、監査カラムの設計以前の問題です。現時点でも色コードを直書きした style 属性を持つ記事が29本残っていて、次にテーマを変えると、また29記事が「更新」されます。最近の記事は var(--bd) のようなCSS変数で書くようになっていて、そちらは22本。移行途中、という状態です。
2つ目は、更新日の欄を持っているコレクションの方が、より悪い状態だったことです。
前述の恒久記事9本は updatedAt を持ち、値があれば「更新」と表示されます。その9本のうち5本は、7月に検索流入の分析を受けてタイトルと概要文を書き換えられています。「重大なエラーの原因と直し方|WordPress復旧手順」が「|WordPress復旧」になる、といった変更で、ページのタイトルタグがそのまま変わる、明確な更新です。
で、5本とも updatedAt は空のままでした。書き換えたのは私たちのチームです。欄があることも、テンプレートが対応していることも知っている。それでも書かなかった。
書かなかった理由を振り返ると、その作業のPRは「SEO改善」という単位で切られていて、変更したファイル10本を横断的に触っていました。1本ずつ記事を読み直して更新日を判断する作業は、そのPRの目的からすると脱線なんですよね。そして更新日を書き忘れても、ビルドは通り、テストは緑で、誰も気付きません。
updated_at を持つテーブルでも、アプリケーション層で更新する実装を忘れれば同じことが起きます。データベースのトリガーや ORM の自動更新でそれを防ぐわけですが、フロントマターにはトリガーがありません。欄を作った時点で仕事が終わった気になっていました。
宣言する更新日と、派生する更新日
ここまでで、更新日には2つの作り方があって、どちらも単独では使えないことが分かりました。
| 作り方 | 何が分かるか | 自社での実測 | 弱点 |
|---|---|---|---|
| 宣言する(フロントマターに人が書く) | 意味が変わったかどうか | 欄を持つ9記事で記入0件。うち5記事はタイトルが書き換えられている | 書かれない。書き忘れを検知する仕組みが無い |
| 派生させる(git の履歴から計算する) | ファイルが変わったかどうか | 73記事が更新済み。テキストだけ比較しても20記事で、表の書式変更5件が残る | 意味の変化を判定できない。正規化のルールが増えるほど脆くなる |
派生の方をもっと賢くする道も考えました。レンダリング後のHTMLからテキストを抜いて比較すれば、表の記法変換は消せます。ただ、そこまでやっても「画像を別の図に差し替えた」は意味の変化なのにテキスト比較では消えてしまい、「誤字を1文字直した」は意味の変化ではないのに残ります。判定の境界を機械に引かせようとすると、ルールが際限なく増える。ここは好みが分かれるところですが、私は意味の判断は人の仕事に残すべきだと考えています。
一方で、宣言だけに頼ると、今回のように0件になる。
落とし所は、宣言を正とし、派生を「書き忘れの検知」に使うことにしました。データベースで言えば、updated_at はアプリケーションが明示的に書く、ただし変更検知トリガーで書き忘れを警告する、という組み合わせです。
決めたこと
まず記事コレクションに updatedAt を足します。恒久記事のコレクションと同じ形で、任意項目です。
// src/content.config.ts(変更案)
const news = defineCollection({
schema: z
.object({
title: z.string().min(1).max(100),
summary: z.string().max(200),
publishedAt: z.coerce.date(),
updatedAt: z.coerce.date().optional(), // 読者に伝えるべき変更があった日。見た目・メタデータの変更では触らない
status: z.enum(['draft', 'published']),
// ...
})
.refine((d) => !d.updatedAt || d.updatedAt >= d.publishedAt, {
message: 'updatedAt は publishedAt 以降の日付にしてください',
}),
});
コメントに書いた「見た目・メタデータの変更では触らない」が運用ルールの本体です。サムネイルの追記、表の罫線、色の変更、著者表記の統一では updatedAt を動かさない。事実の修正、段落の追加、タイトルの書き換えでは動かす。判断に迷ったら動かさない側に寄せる。読者に「更新」と見せて中身が同じだった方が、信用を失う量が大きいと考えるからです。
次に、宣言された値を外に出す場所を揃えます。現状を数えると、外向きの更新日はどこにも出ていませんでした。記事ページのOGPは article:published_time だけで article:modified_time が無く、サイトマップは115件のURL全部に lastmod が無く、構造化データはパンくずリストだけで記事の dateModified はありません。
lastmod については、値が無いなら出さない、が正解だと思っています。全URLに公開日を入れることもできますが、それは「この日以降変わっていない」と主張することになり、73記事で嘘になります。updatedAt が書かれた記事だけ、その日付を出す。
// astro.config.mjs(変更案)。updatedAt を持つ記事だけ lastmod を出す
import { readFileSync, readdirSync } from 'node:fs';
function collectLastmod() {
const map = new Map();
for (const name of readdirSync('src/content/news')) {
const src = readFileSync(`src/content/news/${name}`, 'utf-8');
const hit = src.match(/^updatedAt:\s*(\d{4}-\d{2}-\d{2})/m);
if (hit) map.set(`/news/${name.replace(/\.mdx?$/, '')}/`, hit[1]);
}
return map;
}
const lastmodByPath = collectLastmod();
export default defineConfig({
site: 'https://xecin.jp',
integrations: [
sitemap({
serialize(item) {
const path = new URL(item.url).pathname;
const lastmod = lastmodByPath.get(path);
return lastmod ? { ...item, lastmod } : item; // 無い URL には lastmod を付けない
},
}),
],
});
このコードには改善の余地があります。サイトマップの生成時点ではコンテンツレイヤーの解析結果に触れないので、MDXを正規表現でもう一度読んでいます。Zodを通っていない生の文字列を見ているため、updatedAt を 2026/09/21 のような別書式で書かれるとここだけ取りこぼします。また日付だけの値は UTC の0時として解釈されるので、日本時間で見ると前日扱いになる境目があります。本来はビルド後に dist の情報から組み立てるか、Astroのインテグレーションとしてコンテンツレイヤーの結果を受け取る形にすべきで、そこは次の宿題です。
書き忘れを検知する
最後に、宣言が0件に戻らないようにする仕掛けです。PRで記事の本文が変わっているのに updatedAt が動いていなければ、CIで指摘します。判定には先ほどの「テキストだけ比較」を使います。
#!/usr/bin/env bash
# PR で本文が変わった公開済み記事のうち、updatedAt が動いていないものを列挙する
set -euo pipefail
base="${1:-origin/main}"
git diff --name-only "$base"...HEAD -- 'src/content/news/*.mdx' | while read -r f; do
git cat-file -e "$base:$f" 2>/dev/null || continue # 新規記事は対象外
before=$(git show "$base:$f" | python3 .scripts/body_text.py) # フロントマターを外し、先ほどの normalize の level 3 を通す
after=$(python3 .scripts/body_text.py < "$f")
[ "$before" = "$after" ] && continue # 本文のテキストが同じなら見た目かメタデータの変更
bump_before=$(git show "$base:$f" | grep -m1 '^updatedAt:' || true)
bump_after=$(grep -m1 '^updatedAt:' "$f" || true)
if [ "$bump_before" = "$bump_after" ]; then
echo "::warning file=$f::本文のテキストが変わっていますが updatedAt が更新されていません"
fi
done
エラーではなく警告にしています。テキスト比較は表の記法変換で5件の誤検知が残ることが分かっているので、機械に止める権限までは渡さない。レビューする人が警告を見て、「これは表の書式だけだから動かさなくていい」と判断できれば十分です。逆に言うと、警告を無視する判断をPRの中に残すことになるので、書き忘れではなく「書かないと決めた」記録になります。
ライブラリの話を少しだけ。gitの履歴から lastmod を自動生成してくれる仕組みは、GitHub Actionsのアクションにも、静的サイトジェネレータのプラグインにもあります。今回それを選ばなかった理由は、ここまで書いてきた通り、gitの変更と読者にとっての更新が9割の記事で一致しなかったからです。コミット単位を細かく分ける運用(見た目の変更は別PRにする、など)が徹底できるチームなら、派生だけで十分に成立すると思います。私たちのように、サムネイル付与や配色変更を後から一括で流す運用をしているところでは合いませんでした。
振り返って
テーブル設計で当たり前にやっていることを、MDXの束には当てていなかった、というのが今回の一番大きな反省です。フロントマターはスキーマを持ち、Zodで検証され、ビルド時に読まれる。どう見てもレコードなのに、フォルダに並んだ文章だと思っていた。
そして、欄を作ることと、欄が埋まることは別の仕事でした。9記事0件という数字は、設計を怠った結果ではなく、設計した後に運用を設計しなかった結果です。データベースなら ORM やトリガーが埋めてくれる部分を、フロントマターでは自分たちで用意する必要があった。
今後は、まず色コードを直書きした29記事の表をCSS変数に寄せて、デザイン変更が本文のコミットにならない状態を作るところから始めます。それが終わると、gitの変更と読者にとっての更新の差がかなり縮まるはずで、先ほどの警告の誤検知も減る。updatedAt の運用は、この記事を公開した後に直す記事を最初の1件として、まずは事実を直したときに必ず書く、から始めていきたいと思っています。
ちなみにこの記事の表も、罫線と背景色をインラインの style で書いています。CSS変数を使っているので次の配色変更では変わらないはずですが、そもそも本文に置くべき情報だったのかは、まだ答えを出せていません。