Cloudflare Pages や Cloudflare Workers の静的アセット配信 に llms.txt を置いたら、curl -I で覗いたときの Content-Type が text/plain だけで、charset=utf-8 が付いていないことに気づきました。一部のクライアントが UTF-8 を仮定してくれず、日本語が文字化けして読まれていました。public/_headers に対象パスを書き並べて回避しています。
# public/_headers
/llms.txt
Content-Type: text/plain; charset=utf-8
/robots.txt
Content-Type: text/plain; charset=utf-8
/sitemap.xml
Content-Type: application/xml; charset=utf-8
/docs/*.md
Content-Type: text/markdown; charset=utf-8
/ja/docs/*.md
Content-Type: text/markdown; charset=utf-8
この記事では、Cloudflare Pages の既定 Content-Type に charset が付かないことに気づいた経緯、public/_headers に書き足した内容、そして llms.txt 以外で同じ対処を入れた対象を扱います。
llms.txt が日本語で文字化けして返ってきた
うちのプロダクトサイトでは、LLM 向けの目次ファイルとして /llms.txt を配信しています。中身は日本語と英語が混在した Markdown ライクなテキストで、ファイルは UTF-8 で書いています。
デプロイ後、curl -I https://example.com/llms.txt でレスポンスヘッダーを見ると、Content-Type: text/plain だけが返ってきました。本文を素直に Latin-1 として解釈するクライアント — たとえば charset 推定をしない単純な HTTP クライアントや一部の LLM 取得経路 — から見ると、UTF-8 のバイト列が別エンコーディングとして読まれて日本語が崩れます。Web ブラウザーは BOM やヒューリスティックで救うことがありますが、LLM 側で取りに来る経路はブラウザほど親切ではありません。llms.txt を置いた目的が「LLM に読ませる」ことなので、charset を曖昧にしておく状態は放置できませんでした。
Cloudflare Pages の既定 Content-Type には charset が付かない
Cloudflare Pages は、配信するファイルの拡張子からよく知られた MIME type を推測して Content-Type を返します。.txt なら text/plain、.xml なら application/xml のような形です。ただし charset パラメーターは付与しません。HTTP の text/* で charset が省略された場合、MIME の規定 (RFC 2046) では US-ASCII、旧 HTTP/1.1 (RFC 2616) では ISO-8859-1 を既定として扱う経緯があり、後に RFC 6657 でこの既定は見直されました。それでもクライアント実装には ASCII / Latin-1 を仮定する経路が残っており、UTF-8 で書いた日本語はそのまま別エンコーディングのバイト列として解釈されてしまいます。
サイト側のファイルが UTF-8 で書かれていても、HTTP レイヤーで charset=utf-8 を宣言しない限り、受け取る側にはその情報が届きません。
public/_headers で charset を明示する
Cloudflare Pages には、ビルド出力のルート(Astro や Next.js などフレームワーク経由なら public/)に _headers ファイルを置くと、配信時にレスポンスヘッダーを上書きする仕組みがあります。今回はここに対象パスごとの Content-Type を書き直しました。
# public/_headers
/llms.txt
Content-Type: text/plain; charset=utf-8
/robots.txt
Content-Type: text/plain; charset=utf-8
/sitemap.xml
Content-Type: application/xml; charset=utf-8
/docs/*.md
Content-Type: text/markdown; charset=utf-8
/ja/docs/*.md
Content-Type: text/markdown; charset=utf-8
/docs/*.md のようにワイルドカードでパターンマッチでき、配下の .md ファイルにまとめて適用できます。インデントは半角スペース2つ固定で、見た目より厳格です。タブを混ぜたり1スペースだと無視されることがあるので、エディターのインデント設定は気にしておきます。
_headers は静的アセットに対してのみ効きます。Pages Functions や Workers が動的に返すレスポンスはこのファイルの対象外で、関数側の Response のヘッダーに直接 Content-Type を載せます。今回は静的アセットだけで完結する話なので、_headers だけで間に合いました。
llms.txt 以外で同じ対処を入れた対象
文字化けの懸念があるのは llms.txt だけではありません。同じ理屈で、サイト直下の静的テキストファイル全般が対象になります。_headers に追加した対象は次のとおりです。
/llms.txt— LLM 向けの目次・サマリー/robots.txt— クローラー向け/sitemap.xml— XML だが Cloudflare の既定はapplication/xmlでcharsetなし/docs/*.md— 公開している Markdown 原稿(多言語ロケール別パスも分けて書く)
.html は手元で見た限り text/html; charset=utf-8 で返ってくるケースが多く、追加せずに済んでいます。.json も application/json が UTF-8 を前提にした扱いを受けることが多いため、急いで足す必要はありません。気にする必要があるのは、扱いがはっきり決まらず charset が省略されがちな .txt .md .xml 系です。
確認は curl -I でレスポンスヘッダーを見れば一発です。Cloudflare のキャッシュ更新を待つ必要があるので、デプロイ直後は Cache-Control: no-cache を付けて取り直す癖をつけておきます。
curl -sI -H 'Cache-Control: no-cache' https://example.com/llms.txt | grep -i 'content-type'
# => content-type: text/plain; charset=utf-8
charset=utf-8 が並んでいれば、クライアント側が UTF-8 でデコードしてくれる状態に戻っています。