Nuxt Content の記事タイトルが本文に出ない問題を DocPage 側の自動分岐で解消した
引き金は AI 体験格差の引用記事
朝、マーク・アンドリーセンが「最先端モデルを触っていない人は AI を遅れた像のまま見続ける」と語る引用を持ち込んで、その日の 2026-06-29/ ディレクトリに ai-experience-gap-frontier-models.md として保存させた。honda-sakubun スキルで読点と修飾語順をひと通り整え、公開記事として置いた。
保存後、ローカルの dev サーバで開いて読み直したとき、画面の一番上から本文が始まっていることに気づいた。frontmatter に書いた title がブラウザのタブ名にしか出ていない。記事の中身を読み始めたつもりが、何の記事を読んでいるのか分からないまま段落が立ち上がる。
このサイトには <h1> を手書きで埋めている記事と、frontmatter の title だけで済ませている記事が混ざっていた。後者の側だけ、画面上から見出しが消えていた。
なぜ今まで気づかなかったか
DocPage.vue を覗くと、props.doc.title は useSeoMeta 経由で <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 つあった。
- 既存記事を全件 grep して手書き
<h1>を消す(書き手の意図を破壊する) - frontmatter にフラグを足して書き手に選ばせる(書き手の認知負荷を増やす)
- 本文 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 を見て描画側で吸収する判断のほうが、書き手の脳内モデルを守れる。