← Back to Blog
Journal

静的サイトのdraft運用の落とし穴――「一覧には出ないが公開URLで読める」を塞いだら連鎖が始まった

Astro静的サイト運用設計Decap CMSリンク検査

はじめに

下書きのつもりで draft: true を付けた記事が、ブログ一覧にもサイトマップにも出てこない。ここまでは期待どおりです。ところが記事の URL を直接叩くと、200 が返って本文が表示されました。

静的サイトにおける「公開」とは、要するにビルド成果物に HTML が含まれていることです。一覧に載るかどうかは、その後の話にすぎません。本記事では、この取り違えを塞いだ結果として起きた連鎖――内部リンクの一斉 404、ファイル名と URL のずれ、末尾スラッシュの余計なリダイレクト――を実録で辿ります。個別の記事名や実スラッグは出さず、構成は汎用化して書きます。

背景・課題

対象は Astro 5.16.8 の静的サイトです。記事は Content Collections で管理し、ブラウザ編集に Decap CMS、デプロイは GitHub Actions から rsync で nginx 配信のサーバへ、という構成をとっています。

前提として、Astro には draft の組み込み機能がありません。公式ドキュメントは、draft のようなスキーマ項目を自分で定義し、getCollection() の第2引数のフィルタコールバックで除外する方法を案内しています(Astro, 2026a)。

const publishedBlogEntries = await getCollection('blog', ({ data }) => {
  return data.draft !== true;
});

問題は、このフィルタをどの出力系統に書いたかでした。一覧ページには書いてありました。一方、個別記事の動的ルート [...slug].astrogetStaticPaths() では、フィルタなしの getCollection('blog') を呼んでいました。

getStaticPaths() は「Astro がどの URL パスを事前レンダリングするかを決めるオブジェクトの配列を返さなければならない」関数です(Astro, 2026b)。返せば生成され、返さなければ生成されません。つまりフィルタを書き忘れた側は、draft も含めて全記事の HTML を出力していたことになります。

サイトマップも一貫していました。@astrojs/sitemap は静的生成されたルートを走査し、getStaticPaths() によって生成された動的ルートの項目を出力します(Astro, 2026c)。一覧から消え、サイトマップからも消え、それでも個別ページだけが残る――「一覧に出ない」は「非公開」ではなかった、というのがこの穴の正体です。

本論

穴を塞ぐ――除外は出力系統ごとに書く

修正自体は2行です。一覧ページと同じフィルタを getStaticPaths() にも足します。

export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) => {
    return data.draft !== true;
  });
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

なお post.slug は本サイトが旧式の Content Collections 構成のまま運用しているためで、現行の Content Layer API で作った新規プロジェクトではエントリの識別子は post.id です(Astro, 2026a)。写して使う場合は自分のプロジェクトの世代に合わせてください。

ここで副作用を1つ受け入れることになります。この書き方では開発サーバでも draft が 404 になり、ブラウザでの下書きプレビューができません。プレビューを残したい場合、公式ドキュメントは本番ビルド時だけ絞る書き方を示しています(Astro, 2026a)。

const blogEntries = await getCollection('blog', ({ data }) => {
  return import.meta.env.PROD ? data.draft !== true : true;
});

一般化すると、静的サイトの draft 設計の本体は真偽値の置き場所ではなく、除外すべき出力系統の列挙です。一覧、個別ページ、RSS、サイトマップ、OGP や構造化データ。どれか1つでも漏れれば、そこが公開経路になります。

連鎖①――穴に依存していたものが壊れる

塞いだ直後、公開記事から別記事への内部リンクが 404 になりました。リンク先が draft のままだったからです。

順序を整理すると、こうなります。公開したつもりで draft: false にし忘れた記事があり、それは一覧に出ないので気づかれない。しかし穴のおかげで URL では読めており、他の記事からリンクしても実害が出なかった。結果として、見落としが約2か月間、穴によって覆い隠されていたわけです。

これは Hyrum の法則そのものです。「API の利用者が十分な数に達すると、契約で何を約束したかは重要ではなくなる。システムの観測可能な挙動はすべて、誰かに依存されるようになる」(Wright, 2026)。ここでの契約は「draft は一覧に出さない」で、観測可能な挙動は「URL を知っていれば読める」でした。依存したのは筆者自身のリンクです。利用者が1人でも、この法則は成立します。

なおセキュリティの観点では、未検証の下書きが推測可能な URL で読める状態は、それ自体が望ましくありません。この構成では日付とタイトル由来のスラッグから URL が組み立てられるため、推測の難易度は高くないと考えるのが妥当です(この評価は筆者の判断であり、実際に外部から読まれた形跡の有無は確認していません)。

連鎖②――ファイル名とURLは別物

壊れたリンクを直そうとして、次のずれに当たりました。リンクをファイル名から書き起こしていたのです。

Astro はファイル名からスラッグを生成しますが、その過程で一部の記号が落ちます。実測すると次のようになります。

src/content/blog/2026-01-01-記事タイトル――副題.md
  → /blog/2026-01-01-記事タイトル副題/

ファイル名に含まれる ―― が URL には現れません。したがってファイル名をそのまま貼ったリンクは、最初から 404 でした。リンク先が draft で 404 だった期間はこのずれも隠れており、穴を塞いだ後にまとめて表面化しました。

正規化はもう一層あります。Decap CMS 側でも、エントリのファイル名は slug 設定に従って生成・サニタイズされます。グローバルの slug 設定には encoding(既定は RFC3987 準拠の unicode)、clean_accents、そして安全でない文字の置換文字を決める sanitize_replacement(既定は -)があります(Decap CMS, 2026)。CMS 経由で作られたファイル名と、手で置いたファイル名で規則が揃っている保証はありません。長期運用のブログでは、世代によってスラッグ規則が違うことを前提にしたほうが安全です。

教訓は単純で、内部リンクは記憶やファイル名ではなく、実際に配信されている URL を測ってから書く、ということです。

連鎖③――目視確認を不変条件へ格上げする

もう1つ、リンクを洗っている最中に気づいたことがあります。内部リンクの多くが末尾スラッシュなしで書かれており、アクセスのたびに正規化のリダイレクトを1回挟んでいました。

Astro の build.format は既定値が 'directory' で、「各ページについて index.html を入れ子にしたディレクトリを生成する」挙動です(Astro, 2026d)。また trailingSlash の既定は 'ignore' で、末尾スラッシュの有無にかかわらず URL を照合します(Astro, 2026d)。つまり /blog/foo でも最終的には読めますが、その途中でサーバ側の正規化が入ります。301 は恒久的な移動を示す応答で、ブラウザは Location の URL を自動的に取得し直します(MDN, 2026)。表示は壊れないので、目視では気づけません。

そこで、確認を「都度チェック」から不変条件へ格上げしました。

  1. 内部リンクは末尾スラッシュ付きで書く
  2. 内部リンクは直接 200 のみを正常とする(3xx も異常として扱う)
  3. サイトマップを起点に全ページを走査し、内部リンクを総当たりで検査する

検査は shell のワンライナーで足ります。ドメインは変数にしてあるので、そのまま流用できます。

SITE=https://example.com
curl -s "$SITE/sitemap-0.xml" | grep -o '<loc>[^<]*</loc>' | sed 's/<[^>]*>//g' > pages.txt
while read -r u; do curl -s "$u"; done < pages.txt \
  | grep -o 'href="/[^"]*"' | sed 's/href="//;s/"$//;s/#.*//' \
  | LC_ALL=C grep -Ev '[][;^ *$(){}<>|\\]' | sort -u > internal.txt
while read -r p; do
  printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "$SITE$p")" "$p"
done < internal.txt | sort | grep -v '^200 ' 

最後の grep -v '^200 ' が検査の本体です。何も出力されなければ合格という形にしておくと、CI に載せたときの判定がそのまま書けます。筆者の環境での実測は、サイトマップ登録 20 ページ、そこから抽出したユニークな内部リンク 30 本、すべて直接 200 でした。

2026年8月31日追記(連鎖④):この検査を CI に載せ、本記事を公開した直後に、検査が恒久的に失敗するようになりました。原因は本記事自身です。上のコードブロックに書かれた href="/[^"]*" という文字列を、検査器が内部リンクとして抽出していました。検査器が自分の説明文を検出した、自己参照の偽陽性です。対処として、上のワンライナーにはシェル記号を含む抽出結果を除外するフィルタ(LC_ALL=C grep -Ev ... の行)を追加しています。CI 版ではさらに、サイトマップが取得できず走査対象が空のときに偽合格しないよう、明示的に失敗させる保護も入れました。観測可能な挙動に依存するのは、検査器自身も例外ではありませんでした。

実践への応用

今回の連鎖から一般化できることを3つ挙げます。

第1に、「非公開」は真偽値ではなく出力系統の列挙で定義するdraft: true はフラグにすぎず、それを尊重する責任は出力側それぞれにあります。新しい出力(RSS、OGP 画像、全文検索インデックス)を足すたびに、除外の写し忘れが起きうると考えておくべきです。

第2に、穴を塞ぐ修正は「穴に依存していたものの棚卸し」まで含めて完了とする。今回は塞いだ瞬間にリンク切れが表面化しましたが、これは運がよかったほうです。壊れ方が静かであれば、修正の副作用は後日「原因不明の不具合」として戻ってきます。修正の直後に、その挙動を前提にしていた箇所を能動的に探すのが正しい順序でした。

第3に、目視確認は不変条件に格上げして機械化する。「リンクを直したので確認しました」は、確認した人と時点にしか効きません。「内部リンクは直接 200 のみが正常」という不変条件に翻訳すれば、判定は再実行可能になり、CI に載せられます。3xx を許容しなかったのは、許容した瞬間に「1ホップなら問題ない」という運用上の曖昧さが戻ってくるからです。

もう1点、時間軸の副作用にも触れておきます。draft のまま寝かせた記事を後から公開する場合、本文中の出典は執筆時点のものです。公開直前に出典側の現行仕様を確認し直す工程を挟まないと、古い記述をそのまま公開することになります。出典検証を仕組みとして回す話は以前の記事で扱いました。今回はそれが「時間差で寝かせた原稿」という形で現れた、という整理です。

まとめ

  • Astro に draft の組み込み機能はなく、除外は getCollection() のフィルタで自分で書く(Astro, 2026a)
  • getStaticPaths() が返したパスだけが事前レンダリングされる(Astro, 2026b)。一覧にフィルタを書いても、個別ページに書かなければ HTML は出力される
  • サイトマップは静的生成されたルートから作られる(Astro, 2026c)ため、一覧・サイトマップの両方から消えていても非公開の証明にはならない
  • 観測可能な挙動はすべて依存される(Wright, 2026)。穴を塞ぐと、穴に依存していたものが壊れる
  • ファイル名と配信 URL は別物。CMS 側にも独立した正規化がある(Decap CMS, 2026)ので、リンクは実 URL を測って書く
  • リンク健全性は「末尾スラッシュ付き・直接 200 のみ正常」という不変条件に落とし、サイトマップ走査で機械的に検査する

次は、この検査を GitHub Actions のデプロイ後ステップに組み込み、リンク切れとリダイレクト混入をデプロイのたびに検出する形にする予定です。確認を人の記憶から外すところまでが、この修正の完了条件だと考えています。

参考文献

本記事は技術テーマのため学術論文は参照せず、公式ドキュメント(一次資料)と筆者環境での実測に基づいています。

公式ドキュメント

Web記事