Antigravity 2.11.0 に上げてから、Markdown ファイルを開いたときの見た目が変わりました。冒頭の --- で囲まれた frontmatter が、生のテキストではなく、キーと値が並んだ整形済みのカードとして描かれます。
しばらくは「読みやすくなった」以上の感想を持っていませんでした。ところがある日、いつもどおりファイルを開いたら、1本だけカードにならずに生のテキストのまま表示されていたのです。
原因はタイトルの中のコロンでした。ただ、そこで終わらせずに周辺を調べたところ、エラーを出さないまま値が変わってしまうパターンのほうが多いことがわかりました。落ちてくれる壊れ方は、カードにならないので目で気づけます。困るのはカードが普通に描かれているのに、中身が自分の書いたものと違っている場合です。
そこで分岐条件を一つずつ手を動かして確かめ、公開前に走らせる検査を用意しました。以下はその記録です。
コロンがあると壊れる、ではありませんでした
最初に思い込んでいたのは「値の中にコロンを書いたら壊れる」でした。これは正確ではありません。
Python の PyYAML 6.0.3 で、同じ1行をパターンだけ変えて読ませてみました。
| 書いた行 | 結果 |
|---|---|
title: Antigravity 2.11.0: 何が変わったか | ScannerError(落ちる) |
title: Antigravity 2.11.0:何が変わったか | 文字列として通る |
note: 実行は 12:30 から | 文字列として通る |
title: Antigravity 2.11.0:何が変わったか(全角コロン) | 文字列として通る |
title: "Antigravity 2.11.0: 何が変わったか" | 文字列として通る |
分岐しているのはコロンの有無ではなく、コロンの直後に半角空白があるかどうかでした。12:30 が無事なのも、全角コロンが無事なのも、同じ理由です。YAML はコロンそのものではなく「コロン+半角空白」または「行末のコロン」をキーと値の区切りとして扱います。
ここまでなら、落ちてくれる分だけ親切な仕様です。問題は次からでした。
エラーを出さずに値が変わるパターンが5つありました
同じやり方で範囲を広げて確かめたところ、パースは成功するのに、読み取った値が書いたものと違うケースが並びました。
| 書いた行 | 読み取られた値 | 何が起きたか |
|---|---|---|
title: 見出し #1 の話 | "見出し" | 空白+# 以降がコメント扱いで切り捨てられる |
title: #1 の話 | None | 行全体がコメント扱いになり値が消える |
draft: yes | True(真偽値) | 文字列のつもりが真偽値になる |
draft: no | False(真偽値) | 同上 |
code: 0123 | 83(整数) | 先頭ゼロが8進数として解釈される |
最後の1行は、実際に見たとき二度見しました。0123 と書いたつもりの値が 83 になっています。8進数の 0123 は10進で 83 ですから、YAML としては筋の通った解釈です。しかし商品コードや型番を frontmatter に置いている場合、これは黙って別の値になったのと同じです。
yes と no も同じ性質です。言語コードや短い回答を値に持たせていると、文字列を期待した先に真偽値が届きます。
そして、私が最初に踏んだのはこの表にない6つ目でした。
リスト項目の中のコロンだけは、型そのものが変わります
highlights や tags のように、リストを持つキーがあります。その項目の中でコロンを使うと、こうなります。
highlights:
- 判定できる: 切り分け方がわかるこのブロックは、エラーを出さずに次のように読まれます。
{'highlights': [{'判定できる': '切り分け方がわかる'}]}リストの中身は文字列ではなく、キーが1つだけの辞書になっています。項目をダブルクォートで囲めば、期待どおり文字列として読まれます。
highlights:
- "判定できる: 切り分け方がわかる"厄介なのは、この差がファイルを眺めている限りほとんど見えないことです。インデントも記号も正しく、YAML としても正当です。ビルドも通ります。
では、その値がテンプレートまで届くとどうなるか。Node.js v22 で、辞書になってしまった項目を文字列として扱ってみました。
const item = { "判定できる": "切り分け方がわかる" };
console.log(`${item}`); // [object Object]
console.log([item, "正しい項目"].join(" / ")); // [object Object] / 正しい項目文字列連結の経路なら [object Object] がそのままページに出ます。React のように、オブジェクトを子要素として渡された時点で例外を投げる仕組みであれば、レンダリング側で落ちます。書いた場所と壊れる場所が離れているので、原因にたどり着くまでが遠いというのが、この壊れ方のいちばん面倒なところです。
なお、リスト項目に - a: b: c のようにコロンを2つ書いた場合はパース時に落ちます。1つのときだけ黙る、という覚え方でだいたい足ります。
落ちる側の壊れ方も控えておくと切り分けが速くなります
黙る側ばかり書きましたが、落ちる側も把握しておくとカードが描かれなかったときの当たりがつけやすくなります。同じ環境で確かめた結果です。
| 書いた行 | 結果 |
|---|---|
title: 見出し:(行末がコロン) | ScannerError |
title: のあとにタブ文字 | ScannerError |
owner: @dolice | ScannerError(@ は予約文字) |
title: *強調 の話 | ScannerError(* はエイリアス記法) |
title: [検証] 手順 | ParserError(フロー配列として解釈される) |
title: {検証} 手順 | ParserError(フロー辞書として解釈される) |
一方で title: -3 の補正 は文字列として素直に通ります。先頭のハイフンは、直後に空白がなければリスト項目とは見なされないためです。
こうして並べると、避けるべき文字は @ * [ { # と、タブ、そして「コロン+半角空白」に絞られます。私はこの7つを覚えるより、値に記号が混ざるなら最初からダブルクォートで囲むほうを習慣にしました。囲んでいれば上の表のほとんどは無害になります。
公開前に走らせている40行の検査
黙る壊れ方は目視では拾えないので、機械に読ませます。frontmatter を全数パースして、落ちるものと型が変わったものを分けて報告するだけのスクリプトです。
#!/usr/bin/env python3
"""Markdown の frontmatter を全数パースし、落ちるものと黙って型が変わるものを分けて報告する。"""
import sys, glob, yaml
# キー名 -> 期待する Python 型
EXPECTED = {
"title": str,
"description": str,
"tags": list,
"highlights": list,
}
# 中身まで str であることを求めるリスト型キー
STR_LIST_KEYS = {"tags", "highlights"}
def split_frontmatter(text):
if not text.startswith("---"):
return None
end = text.find("\n---", 3)
if end == -1:
return None
return text[3:end]
def check(path):
problems = []
block = split_frontmatter(open(path, encoding="utf-8").read())
if block is None:
return [("NO_FRONTMATTER", "先頭に --- で囲まれたブロックがありません")]
try:
data = yaml.safe_load(block)
except yaml.YAMLError as e:
line = getattr(getattr(e, "problem_mark", None), "line", None)
where = f"{line + 2} 行目付近" if line is not None else "位置不明"
return [("PARSE_ERROR", f"{type(e).__name__}: {where}")]
if not isinstance(data, dict):
return [("NOT_A_MAP", f"最上位が {type(data).__name__} です")]
for key, want in EXPECTED.items():
if key not in data:
continue
got = data[key]
if not isinstance(got, want):
problems.append((
"TYPE_DRIFT",
f"{key} は {want.__name__} を期待しましたが {type(got).__name__} でした",
))
continue
if key in STR_LIST_KEYS:
for i, item in enumerate(got):
if not isinstance(item, str):
problems.append((
"SILENT_MAP",
f"{key}[{i}] が {type(item).__name__} です。"
f"未クォートのコロンが原因です -> {item}",
))
return problems
def main(paths):
bad = 0
for path in sorted(paths):
problems = check(path)
if not problems:
print(f"OK {path}")
continue
bad += 1
for kind, detail in problems:
print(f"{kind:<12}{path}: {detail}")
print(f"\n検査 {len(paths)} 件 / 問題あり {bad} 件")
return 1 if bad else 0
if __name__ == "__main__":
targets = sys.argv[1:] or glob.glob("content/**/*.md", recursive=True)
sys.exit(main(targets))意図的に壊した5ファイルに対して走らせた結果です。
PARSE_ERROR content/at.md: ScannerError: 3 行目付近
PARSE_ERROR content/hard.md: ScannerError: 3 行目付近
OK content/ok.md
SILENT_MAP content/silent.md: highlights[0] が dict です。未クォートのコロンが原因です -> {'判定できる': '切り分け方がわかる'}
PARSE_ERROR content/tab.md: ScannerError: 3 行目付近
検査 5 件 / 問題あり 4 件要点は3つあります。
第一に、EXPECTED に期待する型を書き下すこと。パースが通ったかどうかだけを見る検査では、highlights が辞書のリストになっていても素通りします。型を宣言してはじめて SILENT_MAP が拾えます。
第二に、リスト型のキーは中身まで見ること。isinstance(got, list) は辞書のリストでも真になります。STR_LIST_KEYS のループがないと、いちばん困る壊れ方だけがすり抜けます。
第三に、問題があれば終了コード 1 を返すこと。公開手順の中でこのスクリプトを挟んでおけば、黙って壊れた値がそのまま外に出ることはなくなります。個人開発でいくつかの Markdown ベースのサイトを回しておりますが、この検査を入れる前は、公開後に読者から見える形になってようやく気づくことがありました。手元で1秒かからずに終わる検査で防げるなら、そちらのほうが良いと考えています。
エージェントにファイルを編集させる場面が増えている今は、なおさら効きます。非 ASCII を含むファイルの編集についても日本語を含むファイルをエージェントに編集させる前に、バイト列で確かめている3つの検査で別の角度から書いています。ルールファイルや agent 定義の frontmatter を厳格に検証したい場合はagent.md の綴り誤りが権限を広げていたのほうが近い話です。
次の一歩
まず、いま手元にある Markdown を1本開いて、Antigravity のエディタで frontmatter が整形カードとして描かれているかを見てみてください。カードにならない1本があれば、そこには落ちる側の壊れ方が入っています。
カードが全部きれいに描かれていても安心はできません。そのときは上のスクリプトを保存して、コンテンツのディレクトリに向けて一度走らせてみてください。黙っていた分は、そこで初めて声を上げます。
お読みいただきありがとうございました。