開発mdx-playground

きっかけ — NVIDIA Q1 FY27 決算記事を即引っ込めたかった

朝、NVIDIA Q1 FY27 決算電話会議の全文書き起こしを記事として書き終え、エディタを閉じる前に出典の Motley Fool のページに戻った。利用規約の欄に「事前の書面同意なしに複製・転写することを禁ずる」と書いてある。記事の本文は丸ごと書き起こしと翻訳に近い構造で、このまま公開し続けるとアウトに振れる。

最初に思いついたのは「frontmatter に published: false を入れる」だった。試したところ、content.config.ts の Zod スキーマで published: z.boolean() を必須にしていたせいで、過去の記事 1000 本すべてに published: true を打ち直さないと SQLite 側で published=false 扱いになる、と判明した。Nuxt Content v3 が内部で持つ SQLite は 「frontmatter に値が無い列も false/null で埋める」 挙動なので、追加した瞬間に既存記事が全部消える。

意味を逆にした。unpublished: true をopt-in で立てる方式に切り替えた。未指定 = undefined/null = 公開、明示 unpublished: true のみ非公開。これなら 1000 本に触らなくていい。

最初の実装で Codex に致命的指摘を3件もらった

unpublished: true を立て、/blog から弾けば終わりだと思っていた。Codex に計画書をレビューさせたら3件の致命傷が返ってきた。

  1. /_raw/<path>.md から本文がダダ漏れする。Nitro hook で content の Markdown を dist/_raw/ にコピーする経路がある。フラグを立てても本文 md は dist に乗ったままで、URL を直叩きすれば全文が読める。
  2. /search/project-timeline が unpublished をフィルタしていない。タイトルと description が公開 payload に載るので、検索ページから記事タイトルだけ拾える。[...slug].vue のディレクトリ一覧クエリも同じ穴を持っていた。
  3. dist 側の閉塞確認が無い。フラグを立てた直後にビルドして「本当に出てない」を機械的に証明する手段が無い。回帰したら気づけない。

朝の「フラグ立てて終わり」が、夕方には「Phase -1: 漏えい遮断機構を先に作る」に膨らんだ。

Phase -1.1 — _raw 漏えいの遮断

apps/web/nuxt.config.ts の Nitro hook で、content/ の Markdown を dist/_raw/ にコピーしている部分(copyMarkdownFiles 周辺)を Claude Code に修正してもらった。isUnpublishedFrontmatter() ヘルパを切り出し、copyMarkdownFilesgetContentRoutes(プリレンダー対象列挙)の両方で除外する。両方を直さないと、片方は md が消えるのにもう片方が HTML を生成してリンクを張る、というちぐはぐが起きる。

verify-raw-files.mjs も書き直してもらった。これまでは「content にある md は dist/_raw に存在する」を検証していたが、unpublished は逆に「存在しない」が正しい状態になる。検証スクリプトとフラグの意味を揃えた。

Phase -1.2 — payload 漏えいの遮断

共通ヘルパ app/utils/article-publish.tsfilterPublic() を作り、3 ページのフィルタを一箇所に集約した。

  • app/pages/search.vue
  • app/pages/project-timeline.vue
  • app/pages/[...slug].vue のディレクトリ一覧クエリ
  • app/composables/useBlogArticles.ts

ここで Codex がもう一発刺してきた。「computed 内の filter だと、asyncData の戻り値(= payload)には unpublished 記事が乗ったまま、クライアント側だけ非表示にする構造になる」。dev でブラウザの DevTools を開いて payload を覗いたら、たしかに nvidia-fy27-q1-earnings のタイトルが JSON に乗っていた。asyncData の戻り値段階で filterPublic を適用するように直し、ブラウザに送る前に消えていることを確認した。

tests/article-publish.test.ts を 5 件追加した。dev で /search /project-timeline /2026-05/2026-05-22 /blog を順に開いて、nvidia-fy27 が 0 件であることを目で確認した。

Phase -1.3 — sitemap / redirects / OG画像からの除外

  • scripts/generate-sitemap.mjsloadNoindexPaths() に unpublished パスを足した(defense-in-depth)
  • scripts/generate-redirects.mjs の publicFiles 生成段階で unpublished を除外(canonical URL を _redirects に流さない)
  • nitroConfig.prerender.routes から unpublished URL を除外(getContentRoutes() 内で対応済み)
  • OG 画像 Worker は別リポジトリだが、useOgSignature.ts で HMAC 署名必須にしてあり、OG_SECRET を持たない攻撃者が URL を推測しても OG を取得できないことを確認

Phase -1.4 — dist を実際に走査する postgenerate 検証

ロジックで除外しても、何かのリグレッションで再混入する可能性は残る。scripts/verify-unpublished-excluded.mjs を新規作成して、ビルド後の dist を機械的に走査する。

検証項目:

  • dist/<path>/index.html が存在しないこと
  • dist/_raw/<path>.md が存在しないこと
  • dist/sitemap.xml に該当 URL が含まれないこと
  • dist 配下の HTML に該当 path への href が含まれないこと
  • dist/_nuxt/ の JS/JSON バンドルに該当 path が含まれないこと

1 件でも失敗したら process.exit(1)package.jsonpostgenerate に組み込んで、pnpm generate の最後に必ず走るようにした。

副産物 — CLAUDE.md に「pnpm generate を毎回走らせるな」と書いた

Phase -1 を作っているあいだ、pnpm generate を確認のたびに走らせていたら 10 分 × 数回でセッションを溶かした。1000 本超の記事を SSG で処理するので、build-time-only コード以外は dev+ユニットテストで先に検証し、pnpm generate はリリース直前の 1 回だけ、というルールをプロジェクト直下の CLAUDE.md に明文化した。Claude Code に「確認のために pnpm generate を走らせます」と提案させない仕掛けを置いた、という意味でもある。

Phase 1 — 19 件への適用

機構が閉じたところで、既存記事の棚卸しに移った。

  • 候補 46 件を memo/2026-05-22/unpublished-candidates-stocks.md に dump し、Tier 1〜4 で分類
  • Tier 1 の 16 件を scripts/mark-unpublished.mjs で一括非公開化。idempotent に動くので、すでに unpublished: 行がある記事はスキップ、frontmatter が無いファイルも壊さずスキップ
  • AMD-OpenAI、NBIS、MRVL、Micron×2、AAOI、Lumentum、Bloom、SNDK、memory-investment、Cerebras、STF、NVDA BofA PT、stock-information×3 を非公開化
  • Tier 2(6 件)も個別に判断して非公開化。教育的要素はあるが個別銘柄の売買判断・PT 収集に寄っているもの

NVIDIA Q1 記事と合わせて 1 → 19 件に拡大した。dev で /blog/unpublished を開くと 19 件並び、/blog/search から nvidia-fy27 を含む 18 件がすべて消えている。

/blog 構造の分離

公開記事は /blog、非公開記事は /blog/unpublished に分けた。/blog/unpublished は dev でだけアクセスできる。本番では 404 を返す。トップページの「最近の記事」セクションの脇にも「ブログ非公開」カードを置き、dev のときだけ表示する。本番ビルドではコンポーネント自体が描画されない。

pnpm generate で見つかった内部リンク漏れ

機構を全部閉じたつもりで pnpm generate を走らせ、verify-unpublished-excluded.mjs に通したら 9 件落ちた。原因は「公開済みの日記から、非公開化した記事へ Markdown リンク [テキスト](/path) が残っている」だった。本文 HTML はビルド済みなので、フラグ立てだけでは消えない。

Claude Code に「unpublished フラグを外すか、リンク側をプレーンテキスト化するか」を提案させた。フラグは残したいので、リンク 12 箇所を一括 Edit で、「NVIDIA Q1 FY27 書き起こし」への内部 Markdown リンクをプレーンテキスト NVIDIA Q1 FY27 書き起こし(未公開) に置き換えてもらった。再ビルドで verify-unpublished-excluded.mjs が緑になった(なお、この記事自身も説明用にリンク記法を本文へ書いてしまい漏えい源になっていたため、2026-06-01 に同じ要領でプレーンテキスト化した)。

Vitest 化 — Markdown ソース段階で潰す

pnpm generate の 10 分を待たないと拾えないバグ、という構造が気持ち悪い。検証スクリプトのうち「公開済み記事から非公開記事への Markdown リンク検出」だけはソースの md だけで判定できるので、ロジックを app/utils/find-leaked-unpublished-links.ts に切り出した。

純粋関数として、ArticleSource[] を受け取り LeakedLink[] を返す形にした。frontmatter 抽出、unpublished 判定、path 抽出も全部分離。tests/unpublished-link-leakage.test.ts で実 content/ を流し込むテストを書き、pnpm test:run で毎回拾えるようにした。これで「フラグを立てた瞬間、リンクが残っていれば数秒で落ちる」流れに変わった。

test:run を build に組み込む案 → revert

ここまで来て「measure-deploy.ps1package.jsonprebuildpnpm test:run を前置きすれば、漏えいリンクを残したまま pnpm generate が走ることは無くなる」と思いついた。組み込んでみたら、unpublished とは別件のテスト失敗が 22 件あり、pnpm test:run が常に赤い状態だった。前置きを足しても build が永遠に通らない。

一旦 revert。別件のテスト失敗は別記事の計画書(memo/2026-05-22/test-failures-recovery-plan 系)に切り出し、そちらが緑になってからもう一度組み込む段取りにした。

完了報告

  • 1. Vitest ユニットテスト: tests/article-publish.test.ts(5件)と tests/unpublished-link-leakage.test.ts(純粋関数 + integration)を追加、いずれも pass
  • 2. Playwright E2E: 純粋関数とビルド時フィルタの変更のためスキップ。dev での手動確認で代替
  • 3. Vitest coverage: app/utils/find-leaked-unpublished-links.ts を新規作成、純粋関数として完全網羅
  • 4. Vitest bench: パフォーマンス重要処理ではないためスキップ
  • 5. セキュリティ / パフォーマンス / SRE 自己レビュー: _raw 漏えい、payload 漏えい、sitemap、redirects、dist HTML href、_nuxt/ バンドルの 6 経路を遮断。postgenerate の検証スクリプトで回帰検知

残タスク(明日以降)

  • Tier 3(17 件)の据え置き記事を再度レビューし、線引きを言語化する
  • Tier 4 diary(6 件)を 1 日 5 本ペースで個別判定
  • 別件のテスト失敗 22 件を解消し、pnpm test:run を build の前置きに戻す
  • CLAUDE.md / content-management スキルに「新規記事作成時の公開/非公開判定フロー」を追記

今日の学び

unpublished: true を立てるだけで漏えいは止まらない。Nuxt Content v3 の SSG では本文 md、payload JSON、sitemap、redirects、dist HTML の href_nuxt/ の JS バンドルと、6 つの経路すべてを閉じないとどこかから漏れる。Codex のレビューが入らなかったら、フラグだけ立てた状態で「閉じたつもり」になっていた。

そして「機構を閉じる前に記事を増やすと、増やすほど漏えい面が拡大する」順序の罠を踏みかけた。Phase -1 を先に終わらせて、Phase 1 の 19 件に進めたのは正解だった。