Nuxt Content の記事タイトルが本文に出ない問題を DocPage 側の自動分岐で解消した

開発mdx-playground

引き金は AI 体験格差の引用記事

朝、マーク・アンドリーセンが「最先端モデルを触っていない人は AI を遅れた像のまま見続ける」と語る引用を持ち込んで、その日の 2026-06-29/ ディレクトリに ai-experience-gap-frontier-models.md として保存させた。honda-sakubun スキルで読点と修飾語順をひと通り整え、公開記事として置いた。

保存後、ローカルの dev サーバで開いて読み直したとき、画面の一番上から本文が始まっていることに気づいた。frontmatter に書いた title がブラウザのタブ名にしか出ていない。記事の中身を読み始めたつもりが、何の記事を読んでいるのか分からないまま段落が立ち上がる。

このサイトには <h1> を手書きで埋めている記事と、frontmatter の title だけで済ませている記事が混ざっていた。後者の側だけ、画面上から見出しが消えていた。

なぜ今まで気づかなかったか

DocPage.vue を覗くと、props.doc.titleuseSeoMeta 経由で <title> タグと OG 画像メタデータにしか流れていなかった。本文の <h1> レンダリングは <ContentRenderer> 任せで、AST に h1 ノードがあればそれが描画され、無ければ画面には何も出ない。

過去にこのサイトで記事を書き始めた頃は、本文の冒頭に # タイトル を手書きで入れる癖が残っていた。最近の記事はそれを省略して frontmatter だけで済ませている。古い記事は表示が崩れず、新しい記事は表紙のない本のように始まる。読み手としての違和感を、書き手としての慣れが上書きしていた。

設計の分かれ道:コンポーザブル化か、DocPage 直書きか

最初に浮かんだ案は「タイトル自動補完ロジックを composables/useDocTitle.ts に切り出す」だった。コンポーザブル化しておけば将来 takken 配下の独自レイアウトページや、新しいページタイプからも呼べる。

ただ、実際にレンダリングを担っているコンポーネントは DocPage.vue 1 箇所しかない。/takken/ 配下も別 .vue ではなく DocPage の中で path.startsWith("/takken/") 分岐で出し分けている。再利用先が無い純粋関数をコンポーザブルに昇格させても、追跡対象のファイルが 1 つ増えるだけで割に合わない。

最終的に、判定ロジックを DocPage.vue<script setup> 内に computed 2 本で閉じ込めた。

const hasH1InBody = computed(() => {
  const body = props.doc.body as { children?: Array<{ tag?: string }> } | undefined;
  const children = body?.children;
  if (!Array.isArray(children)) return false;
  return children.some(node => node?.tag === "h1");
});

const showAutoTitle = computed(() => {
  if (!props.doc.title) return false;
  if (props.doc.path?.startsWith("/takken/")) return false; // takken は独自レイアウト
  return !hasH1InBody.value;
});

テンプレ側は素直に 1 行追加するだけ。

<h1 v-if="showAutoTitle" class="doc__title">{{ doc.title }}</h1>

なぜ「

有無」で分岐するのか

frontmatter から無条件に <h1> を差し込むと、手書きで # タイトル を入れていた古い記事は h1 が二重になる。SEO 上も「ページ内 h1 は 1 個」が安全側で、機械学習で SERP を組むクローラが混乱する余地を残したくない。

選択肢は 3 つあった。

  1. 既存記事を全件 grep して手書き <h1> を消す(書き手の意図を破壊する)
  2. frontmatter にフラグを足して書き手に選ばせる(書き手の認知負荷を増やす)
  3. 本文 AST に h1 があるかをコンポーネント側で見て自動分岐する

3 を選んだ理由は、書き手は「タイトルを画面に出したい」しか考えていないからだ。表示の重複は描画側の責務であって、書き手に判定材料を持たせる話ではない。

@nuxt/content がパース後に渡してくる doc.body.children は AST の浅い配列で、children.some(node => node?.tag === "h1") だけで先頭付近の h1 が拾える。深い入れ子は h1 にならないのでこれで足りる。

SSR と CSR で同じ AST を見る

SSR と CSR で参照ソースを変えると hydration mismatch が起きる。props.doc.body は SSR の payload にも CSR の hydration 後にも同じ AST が乗っているので、両側で hasH1InBody の戻り値が一致する。onMounted 内で DOM を querySelector で走査して <h1> を探す案も浮かんだが、SSR 時に判定できず初回描画でタイトルが出ない/出るが瞬く挙動になる。AST 側を 1 ソースに固定して回避した。

/takken/ 配下だけは別件で DocPage 内に独自レイアウト分岐があり、そこでタイトル領域を別途持っているので showAutoTitle から明示除外した。条件式に書いた path?.startsWith("/takken/") は、将来 takken 側の構造が変わったときに grep で引っかかる場所にしておきたかった。

やってよかったこと

  • 引用記事を読み返したときに、ようやく「何の記事を読んでいるか」が見出しで分かるようになった
  • 既存記事の手書き <h1> をひとつも壊さずに済んだ
  • ロジックを DocPage.vue の上から 250 行付近に隣接させたので、後から doc 周りを触る人が判定意図を読み落としにくい

振り返り

「コンポーザブルに切り出すべきか」を毎回反射で考えると、再利用先のない関数を量産しがちになる。今回は判定対象が 1 コンポーネントに閉じていて、<script setup> の上から数十行で完結する規模だったので、外に出さずに済ませた。

書き手の意図を壊さない自動化は、書き手が考えるべきことを 1 つ減らす方向で設計する。フラグを増やす方に逃げると、書き手側に「毎回 display_title: true を付け忘れない」というルールが生まれて、半年後の自分が確実に踏む。AST を見て描画側で吸収する判断のほうが、書き手の脳内モデルを守れる。