読む体験をつくる — 目次の自動生成から読了状態の保存まで

執筆者:

カテゴリ:

記事を5本公開したあと、読む側の体験を作り込みました。目次の自動生成、追従する目次、読了状況の表示と保存、スマートフォンのメニュー、読了目安の時間。あわせて、トップページの見た目も見直しています。

この記事は、その実装記録です。ただ機能を並べるのではなく、採用しなかった案と、その理由を中心に書きます。何を作ったかより、なぜそう決めたかのほうが、後から読んで役に立つためです。

この記事で扱うこと

HTML文字列からの見出し抽出、Reactのサーバーコンポーネントとクライアントコンポーネントの境界、IntersectionObserverによる現在位置の判定、localStorageとハイドレーションのずれ、そして「運用で止まらない設計」という考え方。最後に、生成AIが使えない環境でサムネイル画像を作った方法にも触れます。

手書きの目次は、いつか必ず壊れる

これまでの記事には、本文の先頭に目次を手書きしていました。見出しと同じ内容を、リンク付きのリストとしてもう一度書いていたわけです。

この方式には構造的な弱点があります。同じ情報が2か所にあるということです。見出しを1つ足したら目次にも足す、順番を入れ替えたら目次でも入れ替える。どちらかを忘れた瞬間、目次と本文がずれます。

しかも厄介なのは、ずれていても画面上は正常に見えることです。目次に無い節があっても、リンク先が消えていても、ページは普通に表示されます。壊れていることに気づく手がかりがありません。

本文から機械的に作れば、この不整合は原理的に起こらなくなります。情報の出どころが1つになるからです。

HTMLは「ただの長い文字列」でしかない

実装で最初に理解しておく必要があるのは、記事本文がどういう形でReactに届くかです。

WordPressから返ってくる本文は、HTMLの文字列です。React側から見ると、これはコンポーネントの木ではなく、ただの長いテキストです。<h2> が何個あるかを数えることも、その中身を取り出すこともできません。React上の要素ではないからです。

したがって、見出しを拾うには文字列のまま解析するしかありません。

src/lib/toc.ts
// h2 と h3 を拾う。後方参照を使い、開始タグと同じ種類の閉じタグに対応させている
const HEADING_PATTERN = /<(h2|h3)([^>]*)>([\s\S]*?)<\/\1>/gi;

export function buildToc(content) {
  const toc = [];
  let autoIdCount = 0;

  const html = content.replace(HEADING_PATTERN, (whole, tag, attrs, inner) => {
    const text = toPlainText(inner);
    if (!text) return whole;

    // 既に id があるなら、その id をそのまま使う
    const existingId = extractId(attrs);
    const id = existingId ?? `section-${++autoIdCount}`;

    toc.push({ id, text, level: tag.toLowerCase() === "h3" ? 3 : 2 });

    if (existingId) return whole;
    return `<${tag}${attrs} id="${id}">${inner}</${tag}>`;
  });

  return { html, toc };
}

ここで意識したのは、既にidが振ってある見出しは、そのidを使い回すという点です。

新しく連番を振り直すほうが実装は簡単ですが、それをやると公開済み記事のアンカー付きURLが全部変わります。外部から /blog/xxx#why-headless のようなリンクを張られていた場合、それが黙って壊れます。URLは公開した瞬間に他人が持つ資産になるので、こちらの都合で変えてはいけません。

正規表現でハマった箇所

どの記事に手書きの目次が入っているかを調べたとき、5本中3本しか該当しないという結果が出ました。原因は g フラグ付きの正規表現で test() を繰り返したことです。この場合、前回の検索位置が記憶されていて、1回おきに失敗します。件数が想定と違ったので調べ直して気づきました。数が合わないときは、だいたい数え方のほうが間違っています。

サーバーで作れないものがある

目次を追従させ、いま読んでいる節をハイライトし、読了状況をバーで示す。これらはすべてスクロール位置に依存します。

ここで、Next.jsのApp Routerにおける重要な区別が出てきます。ページは既定でサーバーコンポーネントとして動きます。サーバー側でHTMLを組み立てて返すため、ブラウザに送るJavaScriptが減り、表示が速くなります。

しかしサーバーが作れるのは「最初の1枚」だけです。スクロール位置は、そのあとブラウザの中で刻々と変わる値で、サーバーには存在しません。

そこで、こうした部分だけをクライアントコンポーネント(ファイル先頭に "use client" と書く)に分けます。

やりたいこと どちらで動かすか 理由
本文から目次を抽出する サーバー 本文が手に入った時点で結果が決まる。ブラウザに解析処理を送る必要がない
目次を表示し、開閉する クライアント 開いている・閉じているという状態を持つ
いま読んでいる節を判定する クライアント スクロール位置はブラウザにしかない
読了目安の時間を計算する サーバー 本文の文字数から決まる。何度計算しても同じ

「取り出す」処理はサーバー、「見せる・動かす」処理はクライアント。この線引きを最初に決めておくと、迷いが減ります。

スクロールのたびに全部測るのをやめる

いま画面に見えている見出しを判定する方法として、最初に思いつくのはスクロールイベントです。スクロールのたびに、すべての見出しの座標を測って比べる。動きはします。

ただしこの方法は、見出しが増えるほど重くなります。1本目の記事は見出しが26個あるので、スクロールのたびに26回の座標計算が走ることになります。スクロールイベントは1回のスクロール操作で何十回も発火するので、掛け算で効いてきます。

ブラウザには、この用途のための仕組みが用意されています。

src/components/TableOfContents.tsx
const observer = new IntersectionObserver(
  (entries) => {
    const visible = entries
      .filter((entry) => entry.isIntersecting)
      .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);

    if (visible[0]) setActiveId(visible[0].target.id);
  },
  {
    // 画面全体ではなく「上から80px〜画面の30%まで」の帯だけを対象にする
    rootMargin: "-80px 0px -70% 0px",
    threshold: 0,
  },
);

headings.forEach((heading) => observer.observe(heading));

IntersectionObserverは「この要素が画面に入った・出た」をブラウザ側で判定して教えてくれる仕組みです。座標計算をこちらでやらないので、見出しが増えても負荷がほとんど変わりません

rootMargin の指定には意味があります。画面全体を対象にすると、見出しが画面の下端に入った瞬間から「いま読んでいる節」と判定されてしまい、実際に読んでいる場所とずれます。上から80pxのあたりから画面の30%までという帯に絞ることで、体感に合わせています。

進捗バーは、どこに置くかで意味が変わる

読了状況を示すバーは、最初、画面の最上端に固定して表示していました。よく見かける形です。

しかし実際に置いてみると、2つ問題がありました。ヘッダーと重なって見づらいこと。そして何の進捗なのかが、見ただけでは分からないことです。細い線が伸び縮みしているだけでは、それが読了率なのか、読み込み中の表示なのか、判断できません。

そこで目次の中に移し、上に文字を添えました。

目次パネルの表示
読了まで              あと 58%
━━━━━━━━━━━━━━━━━━━━━

目次
  まず全体像
  なぜヘッドレスにしたのか
  ...

目次という「記事全体の地図」と同じ枠に入ることで、バーが何を表しているかが説明なしに伝わります。同じ部品でも、置く場所で有用にも無意味にもなります。

数値は「読んだ割合」ではなく「残りの割合」にしました。読み手が知りたいのは、通ってきた距離ではなく、あとどれくらいで終わるのかという見通しだと考えたためです。

細かいが効く指定

パーセントの数字には tabular-nums という指定を入れています。数字の幅を揃えるCSSで、これが無いと 9% から 10% に変わる瞬間に桁が増え、文字の位置が横に揺れます。動くべきでないものが動くと、それだけで安っぽく見えます。

自分の設計を取り下げた話

読了状態をブラウザに保存し、次に開いたときも「読み終えた記事」と分かるようにしたい、という要望がありました。

私は最初、これに反対しました。理由はこうです。ページを開き直した直後、読者は記事の先頭にいます。そこで進捗バーが空なのに文字だけ「読み終えました」と出ていたら、表示同士が食い違って見えます。バーの仕事は現在位置を示すことなので、そこに過去の履歴を混ぜると、どちらの情報も信用されなくなる——そう考えました。

この指摘自体は間違っていなかったと思います。ただし、そこから引き出した結論が違っていました。

最終的に採用したのは、読了済みならバーを満杯の緑で固定するという形です。現在位置の表示をやめ、「この記事は読み終えている」という状態の表示に切り替えます。スクロールを戻しても「あと58%」には戻りません。

src/components/ReadingProgress.tsx
// 一度「読了済み」になったら、その表示のまま固定する。
// スクロールを戻すたびに表示が行き来すると、
// 読み終えたという事実が揺らいで見える。
const [finished, setFinished] = useState(false);

useEffect(() => {
  if (readBefore || remaining === 0) setFinished(true);
}, [readBefore, remaining]);

私が見落としていたのは、読み手にとって、読み終えたという事実のほうが、いま何%の位置にいるかより重要だという点でした。矛盾を避けようとして、優先順位の低いほうを守っていたことになります。

設計の議論では「どちらが正しいか」より「どちらが読み手の関心に近いか」で決めるほうが、たいてい良い答えになります。

サーバーが知らないことを表示する

読了状態の保存先には、CookieではなくlocalStorageを選びました。

Cookieは、そのドメインへのリクエストのたびに自動でサーバーへ送られます。読了状態はサーバーがまったく使わない情報なので、毎回送るのは通信の無駄です。容量も4KB程度しかなく、記事が増えると窮屈になります。localStorageはブラウザ内に閉じた保存領域で、サーバーには送られず、容量も数MBあります。

ただしlocalStorageを使うと、必ずハイドレーションのずれという問題に当たります。

ハイドレーションとは、サーバーが作ったHTMLに対して、ブラウザ側のReactが「同じものを作れるか」を確認しながら操作を結びつけていく処理のことです。ここで両者の出力が食い違うと、Reactが警告を出します。

読了状態はまさに食い違います。サーバーはその人が記事を読んだかどうかを知りません。localStorageはブラウザの中にしか無いからです。したがってサーバーは常に「未読」として描き、ブラウザは「既読」と判断します。

src/components/ReadBadge.tsx
export default function ReadBadge({ slug }) {
  const [read, setRead] = useState(false);

  // 最初の描画では必ず false のまま。
  // 画面に出たあと(useEffect の中)で初めて localStorage を読む。
  // こうするとサーバーとブラウザの初回の出力が一致する。
  useEffect(() => {
    setRead(hasRead(slug));
  }, [slug]);

  if (!read) return null;
  return <span>読了済み</span>;
}

印が一瞬遅れて出ることになりますが、これが正しい作りです。サーバーが知り得ない情報は、サーバーの描画に混ぜてはいけません。

もうひとつ、localStorageの読み書きは例外を投げることがあります。プライベートモードやブラウザの設定で無効化されている場合です。全ての操作を try/catch で囲み、失敗したら「記録しないだけ」で動き続けるようにしました。付加的な機能のために、記事そのものが表示できなくなってはいけません。

押しても同じものが出てくるリンクを消す

ここからは、実装というより判断の話です。

実績のカードは、これまでカード全体が詳細ページへのリンクになっていました。ところが実際に運用してみると、詳細ページで増える情報は担当役割・実施期間・関連リンクの3つだけで、本文は一覧と同じものが出るだけでした。

押しても、ほぼ同じ内容がもう一度出てくるリンクは、読み手の時間を奪います。そこで、詳細ページにしか無かった項目をカードに取り込み、遷移そのものを無くしました。情報を削るのではなく、1階層に畳んで見せる形です。

外部サイトへのリンクだけは残しています。これは「同じ内容の別ページ」ではなく、実物を見に行く導線なので、押す価値があります。

遷移を消したあとの後始末

どこからもリンクされなくなった詳細ページは、URLを直接叩けばまだ表示されます。これは同じ内容が2つのURLで見られる状態で、検索エンジンからは重複コンテンツとして扱われます。使わないページは、リンクを外すだけでなく削除まで行う必要があります。

情報を持たない画像は、面積の無駄になる

トップページの実績カードには、鮮やかな抽象イラストが載っていました。オレンジ、紫、緑、青。見た目には賑やかです。

しかしその画像は、実際に作ったものではなく、汎用のダミーUIやグラフのイラストでした。読み手はまず画像を見ますが、そこから得られるものが何もありません。カード内で最も面積を取っている要素が、最も情報量が少ない状態でした。

さらに、サイト全体の配色方針と衝突していました。設定ファイルには「ニュートラルなグレー基調 + アクセント1色」と書いてあるのに、画面には5色以上が同時に出ていました。方針と実装がずれていたわけです。

画像を外し、カテゴリのアイコンと名前を置く帯に置き換えました。同時に、本文側にあった重複したカテゴリ表示も削除しています。画像とアイコンで同じことを2回言っていた状態が、これで解消されました。

色は、意味の違いを示すときに使う

スキルの習熟度バーは、スキルごとに違う色が割り当てられていました。これも青1色に統一しました。

判断の根拠はシンプルです。色の違いに意味が無かったからです。HTMLが橙でCSSが青であることに、何の情報もありません。差を表しているのはバーの長さです。色を外しても、失われる情報は1つもありませんでした。

逆に、色を残した箇所もあります。読了済みの印を緑にしたのは、「未読と既読」という意味の違いを示しているからです。

運用で止まる仕組みは、優れていても採用しない

記事のサムネイル画像を作る段になって、いちばん考えさせられた判断がこれでした。

最初の案には、記事ごとに内容を表すアイコンと、SERIES 01〜05という通し番号を入れていました。見た目は整っていました。

これに対して出た指摘が「運用する際に困る」というものでした。もっともだと思います。

連番のほうは分かりやすい問題です。番号は「順序」という情報を画像に焼き付けます。記事を1本追加した瞬間、あるいは途中に差し込んだ瞬間、番号は嘘になります。画像に焼いた情報は後から直せません。

アイコンのほうは、もう少し見えにくい問題です。絵柄と内容は対応していたので、機能としては悪くありませんでした。問題は、記事を書くたびに「この記事のアイコンは何にするか」を決める作業が発生することです。これは判断を要する作業で、数分では終わりません。そして決めきれずに保留した記事は、アイコンが空白のまま公開されます。

ここから引き出せる原則は、こうです。運用が止まる原因になる仕組みは、機能として優れていても採用すべきではない。更新されなくなったブログは、装飾が丁寧なことより痛手が大きいからです。

改訂後のサムネイルで、記事ごとに決める要素はカテゴリとタイトルとサブタイトルだけになりました。カテゴリとタイトルはWordPressに入力済みなので、実質的に新しく決めるのはサブタイトルの1行です。

あわせて、タイトルの文字数に応じて文字サイズを自動で切り替えるようにしました。長いタイトルのときに手で調整しなくて済みます。運用で困る箇所は、あらかじめ潰しておきます。

生成AIが使えない環境で画像を作る

サムネイルは当初、画像生成AIで作る想定でした。しかし使おうとした環境では画像生成が利用できず、別の方法を探すことになりました。

次に試したのがストックフォトの検索です。結果は芳しくありませんでした。「ヘッドレスCMSの構成」「デプロイでハマった話」といった内容に対して返ってきたのは、住宅の設計図、青い積み木、白い展示台。この手の技術記事の内容に対応する写真は、そもそも存在しないという性質の問題でした。

これを貼れば、さきほど実績カードから外したのとまったく同じ状態を作り直すことになります。

そこで採った方法が、HTMLとCSSでデザインして、ヘッドレスブラウザで画像として書き出すというものです。

サムネイル生成スクリプト(抜粋)
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });

for (const article of ARTICLES) {
  await page.setContent(html(article), { waitUntil: "load" });
  await page.screenshot({ path: article.file });
}

await browser.close();

ブラウザを画面なしで動かし、作ったHTMLを表示させ、その画面を撮る。それだけです。この方式には、絵を描く方法として見たときに明確な利点があります。

サイトのヒーローとまったく同じCSS(グラデーション、グリッド、ロゴ)を使えるので、デザインが自然に揃います。タイトルは文字として載るため、内容と必ず一致します。そして修正がききます。タイトルを変えたらコードを1行直して再実行するだけで、全部作り直せます。

サイズは1200×630にしました。ブログ一覧のカードだけでなく、SNSで共有されたときのOGP画像としてもそのまま使える寸法です。

作業中に踏んだ罠

実装そのものより時間を取られた箇所を、3つ記録しておきます。どれも「コードは正しいのに動かない」という形で現れました。

1. 見ていたサーバーが違った

変更を反映したのに画面が404のまま、という状態が続きました。原因はターミナルの警告文にありました。

見落としていた1行
⚠ Port 3000 is in use, trying 3001 instead.

前に起動した開発サーバーが完全に止まっておらず、ポート3000を掴んだままでした。新しいサーバーは3001で起動しており、私たちが見ていた3000は、古い設定で動いていた別のサーバーだったわけです。

2. 設定ファイルを読まないツールがあった

GraphQLの型を再生成しようとして、何度やっても失敗しました。設定はこう書かれています。

codegen.ts
schema: process.env.NEXT_PUBLIC_WORDPRESS_API_URL ?? "http://localhost:8080/graphql"

環境変数は .env.local に書いてあります。しかし.env.local を自動で読み込むのはNext.jsであって、型生成ツールではありません。環境変数が空のまま右側にフォールバックし、起動していないローカルWordPressを見に行って失敗していました。

「同じフォルダにある設定ファイルは、全部のツールが読んでくれる」と思い込んでいると、この種の問題は見つかりません。

3. 古いサイトが表示され続けた

本番サイトを開いたら、更新前の古いデザインが出ました。一瞬デプロイの失敗を疑いましたが、URLに意味のないパラメータを付けて開き直すと、新しいサイトが表示されました。ブラウザのキャッシュです。

共通しているのは、3つとも「コードの問題」ではなかったという点です。コードを疑って読み直しても答えは出ません。何かおかしいと感じたら、コードの外側——どのサーバーを見ているか、どの設定が読まれているか、いつのデータを見ているか——を先に確認するほうが早いことが多いです。

今回の学び

実装の話として3つ、判断の話として2つ、残しておきます。

実装では、同じ情報を2か所に持たないこと。目次を手書きしていたのは、見出しという情報を二重に持っていた状態でした。片方から機械的に作れば、ずれようがありません。

次に、サーバーが知り得ない情報は、サーバーの描画に混ぜないこと。localStorageの中身はブラウザにしかありません。初回は何も出さず、画面に出てから読む。遠回りに見えて、これが正しい順序です。

そして、仕組みに用意された道具を使うこと。スクロールのたびに全要素を測る代わりにIntersectionObserverを使う。自分で書けば動きますが、ブラウザ側で最適化されている処理には敵いません。

判断のほうは、まず面積と情報量を一致させること。大きく見せているものほど、多くを伝えるべきです。意味のない画像が最大面積を占めていたら、それは削るべき対象です。

最後に、運用で止まる仕組みは採用しないこと。これが今回いちばん考えさせられた点でした。作っている最中は、機能が多いほど良いものに見えます。しかし公開したあとに待っているのは、記事を書き続ける日々です。そのとき障害になるものは、どれだけ丁寧に作られていても負債になります。