タグ: Next.js

  • 一部だけ足したつもりが、丸ごと置き換わっていた

    一部だけ足したつもりが、丸ごと置き換わっていた

    1行足したつもりでした。実際には、そのページの共有画像が消えていました。

    しかも消えたのは、その日に触ったページだけではありません。調べたら、以前から9ページで同じことが起きていました。画面には出ず、動作も壊れず、誰も気づいていませんでした。

    この記事で扱うこと

    Next.js のページ情報(メタデータ)は、親と子で「項目ごとに混ざる」のではなく「かたまりごとに入れ替わる」という話です。気づいた経緯と、同じ間違いを繰り返さないための置き方を書きます。

    正規URLを足しに行っただけだった

    きっかけは検索エンジン側の指摘でした。ブログ一覧に絞り込みが付いているため、同じ中身が複数のURLで見える状態になっていて、56件が「重複しています」と報告されていました。

    対処として、各ページに「このページの代表URLはこれです」という指定を足しました。Next.js では alternates というかたまりの中に書きます。

    // 記事一覧ページ
    export const metadata = {
      alternates: {
        canonical: "/blog",
      },
    };

    足したのは1項目です。ところが公開後に確認すると、そのページからRSSフィードの場所が消えていました。

    親に書いてあったものが、丸ごと消えた

    フィードの場所は、全ページ共通の設定として親の側に書いてありました。

    // 全ページ共通の側
    alternates: {
      types: {
        "application/rss+xml": [{ url: "/feed.xml", title: "..." }],
      },
    },

    期待していたのは、親の types はそのまま残り、子で足した canonical が加わることです。実際は違いました。

    入れ替わる単位は「項目」ではなく「かたまり」

    子のページが alternates を書くと、親の alternates はまるごと捨てられます。中の項目どうしは比べられません。

    つまり canonical だけを書いた瞬間、そのページの alternates は「canonical しか無いもの」になります。フィードの場所は、書き足したのではなく、上書きで消えたことになります。

    購読アプリはブログ一覧を最初に見に来ます。そこから場所が消えるのは困ります。

    同じ形が、すでに別の場所で起きていた

    この仕組みが分かった時点で、嫌な予感がしました。同じ書き方をしているかたまりが、もう1つあるからです。SNSで共有されたときの見え方を決める openGraph です。

    共有画像は親の側に1枚だけ置いてあり、各ページはそこに乗るはずでした。ところが個別のページには、こう書いてありました。

    export const metadata = {
      openGraph: {
        title: "...",
        description: "...",
      },
    };

    画像の指定はありません。画像を消すつもりも、当然ありません。それでも alternates と同じ理屈で、親の openGraph はまるごと捨てられます。画像だけが残るということは起こりません。

    公開中のページを1つずつ取り寄せて、実際のHTMLを見ました。

    ページ og:title og:image
    トップ あり あり
    ブログ一覧 あり 無し
    用語集 あり 無し
    プロフィール あり 無し
    各記事ページ ほか あり 無し

    合計9ページで画像が出ていませんでした。og:title は書いてあるので、確認する側から見ると「設定してある」ように見えます。抜けているのは、書いていない項目のほうでした。

    なぜ長いあいだ気づかなかったのか

    この不具合には、気づくきっかけがほとんどありません。

    画面には何も出ません。ページは普通に表示され、リンクも動きます。ビルドも通ります。書き間違いではないので、警告も出ません。「書いていないものが消える」ので、書いたものを見ているかぎり異常が見えません。

    見えるのは自分の外側だけ

    この設定が効くのは、リンクを他のサービスに貼ったときです。共有されて初めて、画像の無い地味なリンクとして出ます。

    つまり自分のサイトを見ているかぎり永久に分かりません。今回も、別件を調べていて仕組みのほうから逆算しただけで、症状から見つけたわけではありません。

    書き写すのではなく、1か所から読む

    直し方は単純で、上書きするページでは、消えるものを毎回書き添えます。

    ただし各ページに同じ内容を書き写すと、いずれ書き忘れます。今回消えていた9ページは、まさに書き忘れの集まりでした。同じ直し方をすれば、同じことが起こります。

    そこで、共通の値を1か所に置いて、そこから読む形にしました。

    export const FEED_ALTERNATE_TYPES = { /* ... */ };
    
    export function alternatesFor(canonicalPath) {
      return {
        canonical: canonicalPath,
        types: FEED_ALTERNATE_TYPES,
      };
    }

    各ページは alternatesFor("/blog") と書きます。「canonical を足す」と書けば、消えるはずのものが自動で付いてきます。共有画像のほうも同じ形にして、既定の画像を1か所に置き、上書きするページはそれを混ぜて返すようにしました。

    仕組みは変えられないので、書き方のほうを変える

    「かたまりごと入れ替わる」という挙動そのものは、こちら側では変えられません。変えられるのは、上書きが必要なときに安全な形しか書けないようにしておくことです。

    注意して書き写す運用は、9ページぶん失敗した実績があります。

    今回の学び

    まず、設定を足すときは「入れ替わる単位」を先に確かめるということ。項目ごとに混ざると思い込んでいたので、1項目を足す作業のつもりでいました。実際にはかたまりの差し替えでした。足し算だと思っていた操作が、代入だったわけです。

    次に、1件見つけたら、同じ形を探しに行くこと。今回いちばん役に立ったのは、直したことではなく「同じ書き方をしている場所は他にないか」と考えたことでした。目に見える不具合は1つでしたが、同じ形をした見えない不具合が9つありました。

    そして、自分の画面に出ない設定は、外から取り寄せて確かめること。共有時の見え方も、検索エンジン向けの指定も、開発中の画面には出てきません。公開中のHTMLを1ページずつ取り寄せて初めて、書いてあるつもりのものが無いと分かりました。書いたコードではなく、出ている結果を見る必要がありました。

  • ソース解説(2) 目次は本文から作る

    ソース解説(2) 目次は本文から作る

    記事の目次は、以前は手で書いていました。見出しを1つ足すたびに目次も直す。直し忘れると、目次に無い節ができます。

    しかも壊れていても画面は普通に見えます。リンクが切れているか、節が抜けているかは、上から順に確かめないと分かりません。

    いまは本文から機械的に作っています。今回はその処理を、実際のコードで読んでいきます。

    この記事で扱うこと

    本文が「文字列」として届くこと、そこから見出しを拾う方法、既にあるidを勝手に変えてはいけない理由、そして読んでいる位置をどう追いかけているかです。

    本文は、部品の木ではなく1本の文字列で届く

    前提から確認します。記事の本文はWordPressからHTMLの文字列として届きます。

    画面に出す側から見ると、これは部品の集まりではありません。<h2>…</h2> という文字の並びが入った、長い1本の文字列です。

    だから見出しを数えるにも、リンクを張るにも、文字列のまま扱うしかありません。ここが出発点です。

    見出しを拾う

    使っているのはこの1行です。

    const HEADING_PATTERN = /<(h2|h3)([^>]*)>([\s\S]*?)<\/\1>/gi;

    3つに分けて読みます。

    部分 拾うもの
    (h2|h3) 見出しの種類
    ([^>]*) タグに付いている属性
    ([\s\S]*?) 見出しの中身

    最後の \1 が効いています。これは「1つめで拾ったのと同じ文字」という意味です。h2 で始まったなら </h2> で終わるものだけを拾います。ここを (h2|h3) と書いてしまうと、<h2> で始まって </h3> で終わる、ありえない組み合わせまで拾ってしまいます。

    [\s\S]*? は「改行を含む、なんでも」。見出しが複数行に分かれていても拾えます。末尾の ? は「できるだけ短く」で、これが無いと最初の見出しから最後の見出しまでを丸ごと1つとして拾ってしまいます。

    中身からタグを落とす

    見出しの中に <code> や <strong> が入っていることがあります。目次には文字だけ欲しいので、タグを落とします。

    このとき、実体参照も戻します。&amp; のままだと、目次に「&amp;」と見えてしまうからです。

    順番 戻す対象 戻した結果
    1 &nbsp; 空白
    2 &lt; <
    3 &gt; >
    4 &quot; 二重引用符
    5 &#39; 単引用符
    6 &rarr; →
    最後 &amp; &
    順番を変えると壊れる

    &amp; を先に戻すと、&amp;lt; がまず &lt; になり、次の行で < になります。

    元の文字列は「&lt; という文字を表示したい」という意味でした。2回変換したせいで、本物のタグの記号に化けています。置き換えの順番だけで結果が変わる例です。

    既にあるidは、絶対に変えない

    見出しにはリンク先としてのidが要ります。無ければ振りますが、既にあるものは触りません。

    const existingId = extractId(attributes);
    const id = existingId ?? ("section-" + (++autoIdCount));

    ?? は「左が無いときだけ右を使う」。ここが分かれ目です。

    idを振り直すと、外から張られたリンクが切れる

    公開済みの記事の見出しに、誰かが #その見出し 付きのURLで言及しているかもしれません。

    順番でidを振り直すと、記事の途中に節を1つ足しただけで、それ以降のidが全部ずれます。ページは開けるので404にもならず、ただ違う場所に着地します。壊れたことに気づく手段がありません。

    そして、idを書き足すときは既にある場合と分けています。

    if (existingId) {
      // 属性をそのまま使い、id は書き足さない
    } else {
      // 属性のうしろに id を1つ書き足す
    }

    属性をそのまま持ち回しているので、既にidがある側で書き足すと id が2つ並びます。分けているのはそのためです。

    見出しへのリンクを、その場で差し込む

    同じ処理のついでに、見出しの末尾へリンクを足しています。

    <a class="heading-anchor" href="#見出しのid"
       aria-label="この見出しへのリンク">
      <span aria-hidden="true">#</span>
    </a>

    「#」は見る人には意味が通じますが、読み上げると「シャープ」としか聞こえません。そこで記号は読み上げの対象から外し、説明を別に持たせています。

    ブラウザ側で後から差し込むこともできますが、ここで一緒に組み立てれば、訪問者に送るJavaScriptが1行も増えません。

    読んでいる位置を追いかける

    ここからはブラウザ側です。いま画面に見えている見出しを光らせています。

    スクロールのたびに全見出しの座標を測る方法もありますが、それは見出しが多い記事ほど重くなります。代わりに、ブラウザ側で交差判定をしてくれる仕組みを使っています。

    rootMargin: "-80px 0px -70% 0px"

    この1行が効いています。画面全体ではなく、上から80pxより下、かつ画面の上から30%までの帯だけを対象にしています。

    なぜ画面全体ではないのか

    画面全体を対象にすると、見出しが画面のいちばん下に入った瞬間から「いま読んでいる節」と判定されます。まだ目は前の節を読んでいるのに、目次の光る場所だけ先に進みます。

    帯を上のほうに絞ると、判定の位置と目の位置が近くなります。実感に合わせるための調整が、この数字です。

    今回の学び

    まず、手で二重に持つものは、いずれずれるということ。目次と本文は同じ情報を2か所に持っていました。片方から作れば、ずれようがありません。今回の処理は、正確に言えば目次を作っているのではなくずれる可能性を消しているほうが本題でした。

    次に、既に外へ出したものは、勝手に動かせないこと。idを振り直せばコードは簡単になりますが、外から張られたリンクが静かに壊れます。自分の都合で変えられるものと、変えられないものがあります。

    そして、正しい判定と、実感に合う判定は別だということ。見出しが画面に入ったかどうかの判定は、どちらの帯でも「正しく」動きます。合うほうを選ぶには、数字ではなく自分で読んでみるしかありませんでした。

  • ソース解説(1) 全体の地図と、境目の引き方

    ソース解説(1) 全体の地図と、境目の引き方

    このサイトの作りについては何度か書きましたが、中のコードそのものには触れていませんでした。

    ここから何本かに分けて、実際のファイルを開きながら説明していきます。まずは全体の地図と、読むために知っておいたほうがいい前提からです。

    この記事で扱うこと

    フォルダの分け方、依存しているものの数、そして「サーバーで動く部品」と「ブラウザで動く部品」がどこで分かれているかです。次回以降、個別のファイルを1つずつ読んでいきます。

    本番で必要な依存は5つしかない

    package.json を開くと、公開後に動かすために必要なものはこれだけでした。

    名前 役割
    next ページを組み立てて配る本体
    react / react-dom 画面を書く仕組み
    graphql-request WordPressへ問い合わせる
    graphql その問い合わせ文を解釈する

    この他に、開発中だけ使うもの(型検査、書式の検査、CSSの生成、型の自動生成)があります。本番の動作に関わるのは上の5つだけです。

    少ないほうが、後で困らない

    部品を足すたびに、その部品の脆弱性を見張る相手が1つ増えます。入れていないものは、更新も監視も要りません。

    目次も検索も日付の整形も、自分で書いてあります。どれも数十行で、外から持ってくるほどの分量ではありませんでした。

    フォルダは3つに分かれている

    コードは src/ の下にあり、大きく3つです。

    場所 入っているもの
    src/app/ URLと1対1で対応するページ
    src/components/ 画面に出る部品
    src/lib/ 画面に出ない処理とデータ

    app/ は特別で、ファイルの置き場所がそのままURLになります。app/blog/page.tsx が /blog に、app/blog/[slug]/page.tsx が /blog/記事名 になります。角かっこは「ここは可変」という意味です。

    ルーティングの設定ファイルはありません。フォルダを作ればURLが増えます。

    境目は「ブラウザが要るかどうか」で引く

    ここがいちばん説明の要るところです。

    いま作っているのは、ほとんどのコードがサーバー側で動く形です。ページはあらかじめ組み立てられ、訪問者にはHTMLとして届きます。ブラウザで動くコードは、必要な部品にだけ付けます。

    その指定が、ファイルの1行目に書く "use client" です。

    書いていない 書いてある
    どこで動くか サーバー サーバーとブラウザ両方
    できること CMSへの問い合わせ、秘密の値の利用 クリック、スクロール、保存
    訪問者に届くJS 増えない そのぶん増える

    このサイトの部品を数えると、ちょうど半々でした。16個ずつです。

    これとは別に、スクロールの位置や保存した設定を読むための小さな処理が3つ、src/lib/ にブラウザ用として置いてあります。部品ではないので上の数には入れていません。

    書いてある側はどれも、ブラウザでしか手に入らないものを触っています。スクロールの位置、クリック、保存した設定、画面の幅。逆に言えば、それらが要らない部品には付けていません。

    「とりあえず付ける」と、全部が重くなる

    "use client" は、そのファイルから読み込んでいるものにも連鎖します。上のほうの部品に付けると、その下にぶら下がる部品まで一緒にブラウザへ送られます。

    迷ったときに付けておくと動きはしますが、動く代わりに、送るものが静かに増えます。境目はできるだけ末端に置くほうがよく、このサイトでも「ボタン1つだけを別部品にして、そこにだけ付ける」という形がいくつかあります。

    型は、CMSの都合に追いつくために使っている

    TypeScriptを使っている理由は、書き間違いを見つけるためだけではありません。

    記事のデータはWordPressから届きます。項目の名前も、値が空になり得るかどうかも、決めているのはCMS側です。手で型を書き写すと、CMS側で項目を足した日から、少しずつずれていきます。

    そこで、CMSが公開している仕様を読み取って型を自動生成しています。コマンドを1つ走らせると、実際に書いた問い合わせ文に合わせた型ができます。

    「空かもしれない」を、こちらの都合で消せない

    自動生成された型は、title や slug を「無いかもしれない」ものとして扱います。実際に下書きや移行中のデータでは空になり得るからです。

    手書きなら、面倒なので「必ずある」と書いてしまうところです。生成された型は、こちらの願望ではなく相手の仕様を写しているので、そこで手が止まって「無かったらどうするか」を考えることになります。

    この先で読んでいくもの

    次回以降、次の順で中身に入ります。

    回 読むもの 話の中心
    2 目次を作る処理 HTMLを文字列として扱う
    3 同意を取る仕組み 保存する形と、読み込まない実装
    4 CMSから取ってくる処理 キャッシュと、失敗する前提
    5 RSSとサイトマップ 文字列を自分で組み立てる場所

    今回の学び

    まず、依存の数は、後から効いてくるということ。今のところ5つで足りていて、そのぶん更新の通知も脆弱性の報告も少なく済んでいます。数十行で書けるものを外から持ってこない、という判断が積み重なった結果でした。

    次に、境目は機能ではなく「何が必要か」で引くこと。ブラウザでしか手に入らないものを使う部品だけを、ブラウザ側に置いています。見た目の複雑さや行数とは関係がありません。

    そして、相手が決めていることは、相手から写すこと。CMSのデータの形はこちらで決められません。手で書き写すとずれていくので、生成させています。ずれないことより、ずれようがない形にしてあることのほうが大事でした。

  • 進捗バーが0から始まらないのは、測る場所を1つ間違えていたからだった

    進捗バーが0から始まらないのは、測る場所を1つ間違えていたからだった

    記事を開いた瞬間、読み進み具合のバーが9.5%を指していました。まだ何もしていません。

    「あと90%」と出ているのを見て、手が止まりました。進み具合を名乗るものが0から始まらないのは、やはり変です。

    この記事で扱うこと

    なぜ0から始まらなかったのか、どこを基準に測るかで何が変わるのか。そして、終わりの位置を動かさずに始まりだけ直す方法です。

    分母を本文の高さに変えたところまでは合っていた

    このバーは一度直しています。

    最初はページ全体の高さを分母にしていました。ところがページの下には、前後の記事へのリンク、一覧へ戻るリンク、フッターが続きます。実測するとその部分だけで1205pxありました。

    つまり本文を読み終えても90%前後にしかならず、100%にするには読むもののないフッターまでスクロールする必要がありました。勢いよく動かす人は慣性で最下端まで行くので満たせてしまい、文章を追って読む人ほど100%にならない。そこで分母を本文の高さに変えました。

    ここまでは正しい判断でした。それでも、始まりの値は0になりませんでした。

    下端で測ると、開いた時点ですでに進んでいる

    計算はこうなっていました。

    画面の下端が、本文の上端からどれだけ入ったか。それを本文の高さで割る。

    この式だと、ページを開いた時点で本文の一部がすでに画面に映っています。実測した記事では、本文の上端がページの297px地点にあり、画面の高さが745pxでした。開いた瞬間に本文の448pxぶんが映っていることになります。本文の高さが4731pxなので、9.5%です。

    式は正しく、意味だけが合っていなかった

    「画面に映った本文の割合」としては、9.5%は正確な値です。計算間違いではありません。

    ただし、この数字が名乗っていたのは「読み進み具合」でした。読み進み具合なら、読み始める前は0です。式が答えていた問いと、表示が名乗っていた問いが、別のものでした。

    上端で測り、分母を移動量に合わせる

    基準を画面の下端から上端に変えました。

    あわせて分母も変えています。上端で測ると、本文の末尾まで読み切ったときの移動量は「本文の高さ − 画面の高さ」になります。本文の高さのままだと、最後まで読んでも100%に届きません。

    スクロール位置 旧(下端で測る) 新(上端で測る)
    0(先頭) 9.5% 0.0%
    297(本文の上端) 15.7% 0.0%
    2000 51.7% 42.7%
    4283(本文の末尾) 100.0% 100.0%
    100%になる位置は動いていない

    直したのは始まりの側だけに見えますが、実際には分子と分母を同時に取り替えています。それでも100%になる位置は同じ4283pxのままでした。

    分子から画面1つぶんを引いたなら、分母からも画面1つぶんを引く。両方に同じ量を効かせているので、終点は動きません。片方だけ直していたら、今度は末尾で100%に届かなくなっていました。

    割り算の正しさは、分子だけを見ても分母だけを見ても判断できません。

    そしてこの4283pxは、読了として記録される位置でもあります。バーが満杯になるのと「読了済み」に変わるのが同じ瞬間になりました。

    本文が画面に収まる記事はどうなるか

    短い記事では「本文の高さ − 画面の高さ」が0以下になります。割り算が壊れる形です。

    この場合は100%を返しています。動かす余地が無く、開いた時点で最後まで見えているからです。読了の判定も同じ扱いになっていて、開いた時点で全区画が埋まります。2つの表示が同じ答えを出すので、ここでも食い違いません。

    今回の学び

    まず、0から始まらない指標は、測る対象と名乗りがずれている合図だということ。9.5%という値そのものは正確でした。式が答えていたのは「画面に映った割合」で、表示が名乗っていたのは「読み進み具合」です。数字が合っているかより先に、何を名乗っているかを確かめる必要がありました。

    次に、割り算は分子と分母を組で見ること。基準を1つずらすと、分子と分母の両方に同じ量が効きます。片方だけ直すと、始まりが直って終わりが壊れます。今回は両方に効かせたので、終点を動かさずに始点だけ直せました。

    そして、同じことを指す表示は、同じ場所で切り替わるべきだということ。バーの100%と読了の記録は、以前は別の位置にありました。いまは同じ4283pxで揃っています。揃っていれば、片方を見て他方を推測できます。ずれていると、どちらが本当なのかを毎回確かめることになります。

  • 「画面に入った」で数えたら、17%残して読了になった

    「画面に入った」で数えたら、17%残して読了になった

    記事を最後まで読んだかどうかを、サイト側で判定しています。その作りを一度入れ替えたあとで、まだ食い違っていることに気づきました。

    調べたら、本文をまだ17%残している位置で読了が成立していました。

    この記事で扱うこと

    「画面に入った」で数えると何が起きるか、それを「通り過ぎた」に変えると何が揃うか。そして、速く動かしたときに何を数えていいのかです。

    本文を区画に分けて、通った跡を見ている

    判定の仕組みはこうです。本文の高さを6つの区画に分け、画面に映った区画に印を付けます。全部に印が付いたら読了とします。

    時間では測っていません。開いて放置しても時間は進むので、読まずに読了になってしまうからです。通った経路そのものを見れば、連続して読んだ人は必ず全部を通り、途中を飛ばした人には穴が残ります。

    ここまでは意図どおりに動いていました。問題は「映った」をどう数えるかのほうにありました。

    最後の区画は、画面に入った瞬間に埋まる

    最初の数え方は「画面と少しでも重なった区画に印を付ける」でした。素直な書き方に見えます。

    この数え方だと、最後の区画は画面の下から顔を出した瞬間に埋まります。まだ読んでいない部分が画面の下に残っていても、区画としては「映った」ことになります。

    実際の記事で測りました。本文の高さが4731px、画面の高さが745px、1区画は788pxです。最後の区画が画面に入るのは、本文の83%まで進んだところでした。

    数え方 最後の区画が埋まる位置 そのとき画面の下に残っている量
    画面に入ったら 本文の83% 17%
    下端まで通り過ぎたら 本文の100% 0%

    読み終わりを指す数字が、2つとも違う場所にあった

    同じページには、読み進み具合を出す細いバーもあります。こちらは「本文の末尾が画面の下端に来たら100%」で計算していました。

    つまり同じ「読み終わり」を指しているはずの2つが、17%ずれた場所を指していました。バーはまだ「あと17%」と言っているのに、記録のほうはもう読了になっている。どちらも動いていて、どちらも例外を出しません。

    同じ言葉で呼んでいたので、違うものだと気づけなかった

    どちらも「読み終わり」と呼んでいました。呼び名が同じだと、中身も同じだと思ってしまいます。

    実際には片方は「画面に入ったか」、もう片方は「画面を通り過ぎたか」を見ていました。言葉のほうが先に一致してしまうと、数字がずれていても照らし合わせる動機が生まれません。

    「通り過ぎた」に変えると、位置が揃う

    区画に印を付ける条件を、下端まで通り過ぎたかどうかに変えました。

    最後の区画の下端は、本文の末尾と同じ位置です。そこを画面の下端が越えるのは、本文を読み切ったときになります。バーが100%になるのと、読了が成立するのが、同じ瞬間になりました。

    直したのは条件式の不等号ひとつぶんですが、意味はかなり変わっています。「見えた」を「見終えた」にしたので、読む側の実感と一致するようになりました。

    速く動かしたときに、何を数えていいのか

    もうひとつ直したところがあります。

    目次から飛んだときに、途中を読んだことにしてはいけません。かといって、速くスクロールしただけの人を弾いてもいけません。この2つを分けるのに、以前は移動量のしきい値を置くことを考えていました。

    置かずに済む書き方がありました。前回の画面の下端と、いまの画面の上端の、大きいほうを下限にします。

    「表示された範囲」は、1本の式で書ける

    ふつうにスクロールしているあいだは、前回の下端のほうが大きくなります。1回の移動が画面の高さに収まるので、通り過ぎた範囲は全部目に入っています。

    大きく飛んだときは、いまの画面の上端のほうが大きくなります。飛んだ距離のうち画面の高さを超えた部分は一度も表示されていないので、そこは数えません。いま映っている1画面ぶんだけが残ります。

    場合分けが要らないだけでなく、処理が詰まって移動が飛び飛びに見えたときも、表示された範囲だけが自然に残ります。しきい値を決めていたら、その値の妥当性を考え続けることになっていました。

    実際のジオメトリで確かめました。連続して100px、300px、700px、744pxずつ動かした場合とPageDownでは、いずれも読了になります。一気に最下部へ飛んだ場合は成立しません。目次から中ほどへ飛んで最後まで読んだ場合は、手前に穴が残って成立しません。飛んだあとに戻って全部読めば成立します。

    今回の学び

    まず、同じ名前で呼んでいるものが、同じものだとは限らないこと。「読み終わり」を2か所で判定していて、どちらもそう呼んでいました。呼び名が一致していると、中身を照らし合わせる機会が来ません。ずれていたのは実装ではなく、言葉のほうが先に揃ってしまったことでした。

    次に、「見えた」と「見終えた」は別の状態だということ。画面に入った時点で数えると、まだ読んでいない部分が残ります。人が読み終えたと感じるのは通り過ぎたときなので、そちらに合わせるほうが実感と一致します。

    そして、しきい値を置く前に、置かずに書ける形を探すこと。今回は下限の取り方を変えるだけで場合分けが消えました。しきい値は、決めた瞬間から「その値でよいのか」を考え続ける対象になります。要らないなら、無いほうが軽い。

  • 一覧が長くなると、探せなくなる

    一覧が長くなると、探せなくなる

    記事の一覧ページに検索欄を付けました。作りながら決めたことを書いておきます。

    結論から書くと、サーバーには問い合わせていません。検索という言葉から想像される仕組みより、だいぶ簡単なものを選びました。なぜそれで足りると判断したかが、この記事の中身です。

    この記事で扱うこと

    検索をサーバー側でやらないと決めた理由、検索の対象に本文を入れなかった理由、そして表記ゆれの吸収です。

    そもそも、なぜ必要になったのか

    記事が20本を超えたあたりで、自分でも過去記事を探せなくなりました。

    「DNSの話、どの記事に書いたっけ」と思って一覧を開き、上から順に見出しを読んでいく。これは読者にやらせる作業ではありません。

    用語集には先に検索を付けていました。116語を目で追うのは無理だと分かっていたからです。記事も同じ状態になりつつありました。

    サーバーに聞かない、と決めた

    検索の作り方は大きく2つあります。

    やり方 仕組み
    サーバー側 入力のたびにCMSへ問い合わせ、絞り込んだ結果を返してもらう
    ブラウザ側 すでに読み込んである一覧を、その場で絞り込む

    WordPressには検索の仕組みが最初から入っていて、GraphQLからも呼べます。素直に考えればそちらです。

    それでもブラウザ側にしたのは、一覧ページがすでに全記事を持っていたからです。

    このサイトの一覧ページは、あらかじめ組み立てたHTMLを配っています。ページを開いた時点で、全記事のタイトル・抜粋・タグはもうブラウザの中にあります。そこにあるものを、わざわざもう一度サーバーに聞きに行く理由がありません。

    聞きに行くと、壊れやすくなる

    入力のたびにCMSへ問い合わせると、CMSが落ちている間は検索も落ちます。

    いまの構成は、CMSが止まっていても閲覧は続けられるようにしてあります。検索のためにその性質を手放すのは、割に合いませんでした。

    もちろん、これは記事数に依存する判断です。数百本になれば一覧ページ自体が重くなるので、そのときは作り直します。いま正しいことと、ずっと正しいことは違います。

    本文は、検索の対象にしなかった

    次に迷ったのが、どこまでを検索の対象にするかです。

    本文まで探せたほうが便利なのは間違いありません。ただ、一覧ページは本文を読み込んでいません。タイトルと抜粋とタグだけです。

    本文まで含めると、一覧ページが運ぶデータは数十倍になります。検索できるようにするために、一覧ページの表示が遅くなる。これは順序が逆だと思いました。

    検索欄を使う人より、一覧をそのまま眺める人のほうが多いはずです。多いほうを遅くして、少ないほうを便利にする交換はしたくありませんでした。

    「便利」は、どこかの負担と交換されている

    機能を足すとき、増えるのは画面の要素だけではありません。読み込む量、壊れる箇所、直す手間も一緒に増えます。

    足す前に「何と引き換えか」を書き出すと、要らない機能はだいたいそこで消えます。

    表記ゆれを、どこまで吸収するか

    日本語の検索でいちばん厄介なのは、同じものを指す書き方が複数あることです。

    同じ記事にたどり着いてほしい入力
    Next.js
    next.js
    nextjs
    NEXT
    ネクスト

    全部を完璧に扱うのは無理なので、機械的に処理できる範囲だけ揃えました。

    やっているのは4つです。全角と半角を揃える、大文字と小文字を揃える、カタカナをひらがなに寄せる、記号と空白を落とす。入力側と、記事側の両方に同じ処理をかけてから比べます。

    これで「WordPress」も「wordpress」も「ワードプレス」も同じ扱いになります。「ネクスト」で Next.js が出るところまでは面倒を見ていません。そこまでやるなら辞書が必要で、辞書は誰かが更新し続けないと古びます。

    用語集と同じ関数を使った

    この処理は、用語集の検索ですでに書いてありました。

    そのままコピーすることもできましたが、コピーした側は直されません。片方の揺れを直したときに、もう片方は古いままになります。共通の場所に出して、両方から呼ぶようにしました。

    関数を1つ切り出すだけの作業ですが、2箇所目が現れた時点でやっておかないと、3箇所目が現れたときにはもう誰も揃えません。

    0件のときに、何を出すか

    最後まで残ったのがこれでした。

    「見つかりませんでした」とだけ出すのは、行き止まりです。探しに来た人を、何も持たせずに帰らせることになります。

    このサイトには用語集があります。記事名では見つからなくても、語の説明としては存在していることが十分あり得ます。0件のときは、その語で用語集を引く導線を出すようにしました。

    行き止まりを作らない

    0件は失敗ではなく、「その言葉では記事が無い」と分かった状態です。次に行ける場所を1つ添えるだけで、意味が変わります。

    今回の学び

    まず、すでに手元にあるものを数えること。検索と聞くと問い合わせる仕組みを想像しますが、一覧ページはもう全記事を持っていました。持っているものを使えば、増える部品はゼロです。

    次に、機能を足すときは何と引き換えかを書くこと。本文まで検索できるようにすると、一覧の表示が遅くなります。多いほうを犠牲にして少ないほうを便利にしていないか、毎回確かめる価値があります。

    そして、同じ処理が2箇所目に現れたら、その時点で1つにすること。コピーした側は直されません。3箇所目が現れてからでは遅すぎます。

    最後に、0件にも行き先を用意すること。見つからなかったという事実は、それ自体が次の手がかりになります。

  • 測れるもので代用すると、測りたいものから離れる

    測れるもので代用すると、測りたいものから離れる

    「記事を最後まで読んだ人が何人いるか」を知りたい、というだけの話でした。単純に見えたので、すぐ作れると思っていました。

    実際には4回作り直しています。そのたびに別の壊れ方をしました。

    この記事で扱うこと

    読了の判定を作り直した4回ぶんの経緯と、そのたびに何を見落としていたかです。最後に、そもそも何を測ろうとしていたのかを整理します。

    1回目:下までスクロールしたかで測った

    最初は素直に、ページの何%までスクロールしたかで判定しました。95%まで来たら読了、という形です。

    これがうまくいきませんでした。分母にしていたページ全体の高さには、本文の下にある前後記事のリンクや一覧へ戻るリンク、フッターまで含まれています。実測すると、その下側だけで約680pxありました。

    分母に、読むものではないものが入っていた

    本文を最後まで読み終えても、進捗は90%前後にしかなりません。100%にするには、読むもののないフッターまでスクロールする必要がありました。

    勢いよくスクロールする人は慣性で最下端まで行くので満たせます。一方、文章を追って読む人は本文の終わりで手を止めます。ゆっくり読む人ほど読了にならないという、目的と逆の結果になっていました。

    2回目:本文の高さを分母にした

    分母を本文だけに変えました。本文の末尾が画面の下端に来た時点で100%になります。読み終えれば100%、という当たり前の対応になりました。

    ただ、これで別の穴が開きます。目次から末尾へ飛んだ場合も100%になります。位置だけを見ている限り、そこに至った経緯は分かりません。

    3回目:滞在時間の条件を足した

    そこで「そのページを実際に見ていた時間が一定以上ある」を条件に加えました。一瞬で駆け抜けた操作を弾くためです。

    条件が2つになった瞬間、画面の表示と記録がずれ始めました。

    回 何で判定したか そこで抜けたもの
    1 ページ全体のスクロール率 読む人ほど到達しない
    2 本文のスクロール率 飛んだ人も通る
    3 本文の到達+滞在時間 表示と記録がずれる
    4 同上(時間を緩和) 読まずに時間だけで通る

    進捗のバーは位置だけを表しているので、末尾に着いた瞬間に満杯になります。しかし記録は時間の条件を満たすまで行われません。バーは終わっているのに、読了済みにならないという食い違いが画面に残りました。

    条件を足すと、それを表す場所も増える

    判定に条件を足すのは簡単ですが、画面に出している表示のほうは1つのままでした。

    1つの表示で2つの条件を表そうとすると、必ずどちらかの状態を隠すことになります。隠された側が読み手には「動かない」ように見えます。

    間に挟まった、数字の問題

    ずれを隠そうとして、記録される前は「あと1%」で止める、という処理を入れました。0%と出しているのに読了済みに変わらない、という矛盾を避けるためです。

    これは失敗でした。今度は「1%のまま動かない」という別の分かりにくさになりました。残っていないものを数字で表そうとしたのが、そもそもの間違いでした。

    数字で表せない状態は、言葉で書く

    「残り」は数字で表せますが、「末尾には着いたが、まだ記録されていない」は数字ではありません。

    ここは数字をやめて、状態をそのまま言葉にするべき場所でした。無理に数値化すると、動かない数字という嘘が残ります。

    4回目:今度は、読まずに完了するようになった

    時間の条件を緩めたところ、逆の症状が出ました。読み終えていないのに、時間の経過だけで完了になります。

    設計上、位置と時間の両方を満たさないと読了にはならないはずです。それでも時間だけで通るということは、位置の条件が常に成立しているとしか考えられません。

    条件が2つあると、片方が壊れたときに気づけない

    2つの条件を「かつ」で結ぶと、片方が常に真になっても、全体としては動き続けます。残った条件が単独の判定として働くだけで、エラーにはなりません。

    気づけたのは、症状が「時間だけで通る」という形で表に出たからです。出方が違えば、そのまま気づかずにいた可能性があります。

    そもそも、測れるもので代用していた

    ここまで来て、やっと本題に気づきました。

    知りたいのは「読んだか」です。しかし測れるのは「画面がそこを通ったか」と「そのページが開かれていた時間」だけです。読んだかどうかは、ブラウザからは直接見えません。

    つまり最初から、測りたいものを測れるもので代用していました。代用そのものは避けられません。問題は、代用していることを忘れて、条件を足したり引いたりしていたことです。

    代用だと分かっていれば、条件の足し方が変わる

    条件を足すたびに「これは読んだことの何を近似しているのか」を書いていれば、位置と時間が別々の近似だと分かったはずです。

    別々の近似を「かつ」で結べば、片方が外れたときに残りが単独で判定を下します。足したのは精度ではなく、壊れ方の種類でした。

    いま考えている形

    次は、時間をやめて経路を見ようと思っています。

    記事をいくつかの区画に区切り、そのすべてが画面を通ったかどうかを判定します。目次から末尾へ飛べば途中の区画を通らないので読了になりません。ゆっくり読む人は必ず全区画を通るので、確実に読了になります。

    時間に頼らずに「飛ばした人を通さない」と「速く読む人を弾かない」の両方が満たせます。何より、近似しているものが1種類になるので、片方が壊れて気づかない、という形にはなりません。

    今回の学び

    まず、測りたいものと測れるものは違うこと。読んだかどうかは見えないので、必ず何かで代用します。代用だと自覚していないと、精度を上げているつもりで壊れ方を増やすことになります。

    次に、条件を「かつ」で結ぶと、壊れても動き続けること。片方が常に真になっても例外は出ません。残った条件がそのまま判定として働くので、症状が出るまで気づけません。条件を足すときは、それぞれが単独になったときに何が起きるかを見ておく必要がありました。

    そして、判定に条件を足したら、表示も足すか、表示のほうを言い換えること。1つの表示で2つの状態を表そうとすると、必ずどちらかが隠れます。隠れた側は、読み手には「壊れている」ように見えます。

  • 読む邪魔をしない

    読む邪魔をしない

    スマートフォンで自分の記事を読んでいて、気になることがありました。

    画面の上に、ヘッダーと目次がずっと貼り付いたままです。どちらも便利なつもりで置いたものですが、読んでいるあいだは、ただ場所を取っています。

    この記事で扱うこと

    常に出しておくものと、必要なときだけ出すものの分け方。「消す」ではなく「薄くする」を選んだ理由。そして、2つの追従する要素が重なったときに起きた、位置の食い違いです。

    貼り付いたものは、面積を占め続ける

    スマートフォンの画面の高さは、だいたい本文15〜20行ぶんです。そこにヘッダーが1行、目次のバーが1行居座ると、実際に読める範囲は1割ほど減ります。

    減るだけならまだしも、目次のバーはいま読もうとしている行の上に半透明でかぶさります。スクロールしている最中がいちばん邪魔です。

    3つの変更

    対象 前 後
    ヘッダー 常に表示 下へ読み進む間は引っ込む
    目次(スマホ) 常に同じ濃さ スクロール中だけ薄くなる
    先頭へ戻る 手段なし ボタンを出す

    1. ヘッダーは「量」ではなく「向き」で判断する

    最初に考えたのは「300pxスクロールしたら隠す」でした。単純ですが、これだと読み進めるほど隠れっぱなしになります。メニューを開きたくなったとき、ページの先頭まで戻ることになります。

    採用したのは、スクロールの向きで判断する方式です。

    判断の基準
    下へ読み進んでいる  → 隠す(読むのに要らない)
    上へ戻し始めた      → 出す(何かしたいはず)
    ページの先頭付近    → 必ず出す

    上へのスクロールは、たいてい「前に戻りたい」「別の場所へ行きたい」という合図です。その動作を、ヘッダーを呼び出す操作として兼ねさせています。新しい操作を覚えてもらう必要がありません。

    細かいが要る配慮が3つあった

    ひとつめはわずかな揺れを無視すること。指の震えや慣性の揺り戻しで数px動くたびに出し入れすると、ヘッダーが点滅します。6px未満の移動は無視しています。

    ふたつめは画面の端で引っ張ったとき。iPhoneでは画面の一番上でさらに引っ張ると、スクロール位置がマイナスになります。そのまま計算すると「上に動いた」と誤判定します。0で止めるようにしました。

    みっつめはメニューを開いているあいだは隠さないこと。開いた本人の操作でヘッダーごと消えたら、何が起きたのか分かりません。

    2. 目次は「消す」のではなく「薄くする」

    目次も同じように隠す案がありました。やめました。

    この目次のバーには読了の進捗も出しています。消すと、どこまで読んだかが分からなくなります。

    邪魔なのは「濃さ」であって「存在」ではない

    困っていたのは、文字の上に別の文字が重なって読めないことでした。薄くすれば下の文字は読めます。位置と進捗は視界の端に残ります。

    指を止めれば元の濃さに戻ります。スクロール中しか邪魔にならないものを、常時消す必要はありません。

    開いているときは薄くしません。開いているのは読むためではなく選ぶためなので、そこで薄くすると目的の見出しを探せなくなります。

    3. 2つの追従が、食い違った

    ここで問題が出ました。

    目次は「ヘッダーの高さ(56px)ぶん下」に貼り付く指定になっています。ヘッダーがそこにある前提です。

    ところがヘッダーは引っ込むようになりました。すると——

    ヘッダーが消えたあと
    画面の上端
       ↓
    (56pxの空白)   ← ヘッダーが居たはずの場所
       ↓
    目次のバー       ← 宙に浮いて見える

    目次だけが、意味のない隙間を空けて浮きます。

    状態を、渡さずに共有する

    ヘッダーが隠れているかどうかを、目次に伝える必要があります。素直に考えると「上から順に受け渡す」ですが、この構成では間にあるページ全体を作り替えることになります。

    選んだのは、ページ全体に1つの値を置く方法です。

    ヘッダーが書き、目次が読む
    ヘッダー: --header-offset = 3.5rem(表示中)/ 0rem(隠れている)
    目次    : 貼り付く位置 = --header-offset

    ヘッダーは自分の状態を書くだけ、目次はその値を見るだけです。お互いを直接知らないまま、位置だけが揃います。

    あえてTailwindを使わなかった箇所

    このサイトのスタイルはTailwind CSSで書いています。今回の位置指定も書けますが、1か所だけ普通のCSSにしました。

    理由は、壊れ方です。Tailwindのクラス名を書き間違えると、CSSが生成されないだけで、ビルドは成功します。

    気づけない壊れ方を選ばない

    位置指定が消えると、目次は貼り付くのをやめて一緒に流れて消えます。エラーは出ません。実際に記事を開いてスクロールするまで分かりません。

    この部分は今回いちばん確かめにくい場所だったので、綴り間違いで静かに消えない書き方を選びました。

    4. 先頭へ戻るボタン

    最後に、よくあるボタンを置きました。ここでも1つだけ気を付けた点があります。

    薄くして消したボタンは、見えないのに押せます。透明度を0にしただけでは、そこにある扱いのままです。キーボードで操作している人には、見えない項目に順番が回ってきます。

    見えないあいだは押せない・順番が回ってこない・読み上げられないの3つを揃えました。見た目と実体を一致させています。

    あわせて、端末の「動きを減らす」設定を尊重しています。滑らかに移動するのが心地よい人ばかりではありません。

    今回の学び

    まず、常に出しておくものと、必要なときだけ出すものを分けること。便利だから常に出す、を積み重ねると、本文が読める面積だけが減っていきます。何のために画面を開いているのかに戻って決めるべきです。

    次に、「隠す」の前に「薄くする」を検討すること。困っているのが重なりなら、濃さを変えるだけで解決することがあります。消してしまうと、その要素が持っていた別の役割まで一緒に失います。

    そして、動かすものが増えたら、位置の前提を疑うこと。固定されている前提で書いた数値は、片方が動き出した瞬間に嘘になります。今回は「ヘッダーの高さ」がそれでした。

    最後に、確かめにくい場所ほど、壊れたら気づく書き方を選ぶこと。同じ結果になる書き方が2つあるなら、間違えたときに黙って消えないほうを選ぶ価値があります。

  • 届かない問い合わせ

    届かない問い合わせ

    問い合わせフォームを作りました。

    実装そのものは小さいのですが、決めることが3つありました。どれも「動くかどうか」ではなく「壊れたときにどう見えるか」の話でした。

    この記事で扱うこと

    メールアドレスを載せない選択、送信先が未設定のときにフォームを出すかどうか、そして失敗をどう伝えるか。共通しているのは「成功したように見える失敗」をどう防ぐかです。

    1. メールアドレスを直接載せない

    いちばん簡単なのは、ページにメールアドレスを書くことです。実装が要らず、壊れる要素もありません。

    ただし、公開した以上は自動で収集され、迷惑メールが届き始めます。個人サイトでも例外ではありません。

    そこで、フォームの送信先だけを外部サービスに任せる形にしました。受け取り用のアドレスがブラウザ側に出ません。

    方式 手間 アドレスの露出
    アドレスを載せる ゼロ する
    自前でメール送信 サーバーと送信サービスの契約が必要 しない
    外部サービスへ送信 登録して1行設定するだけ しない

    自前で用意する案もありましたが、問い合わせ1つのために管理するものが増えます。メール送信サービスの契約、鍵の管理、送信失敗時の扱い。得られるものに対して重すぎると判断しました。

    2. 送信先が未設定のとき、フォームを出すか

    ここが今回いちばん考えた点です。

    外部サービスの送信先URLは、環境変数で渡します。つまり設定を入れるまでは、送信先が空のままです。

    このとき、フォームをどう扱うか。素直に実装すると、こうなります。

    何も考えずに書くと
    フォームは表示される
       ↓
    入力して送信できる
       ↓
    送信先が空 → どこにも届かない
       ↓
    それでも「送信しました」と表示される

    これが最悪の状態です。

    送った側は、届いたと思って返信を待ちます。こちらには何も残りません。問い合わせが来なかったのか、届かなかったのかも分かりません。

    届かない問い合わせは、無いより悪い

    フォームが無ければ、相手は別の連絡手段を探すか、諦めます。どちらにしても相手は状況を把握できます。

    しかし届かないフォームは、相手に「連絡した」と誤認させます。返信が来ないことで、対応が悪いという印象だけが残ります。何もしないより損をしています。

    そこで、設定が無いときはフォームごと出さないことにしました。

    実装
    const ENDPOINT = process.env.NEXT_PUBLIC_CONTACT_ENDPOINT;
    
    if (!ENDPOINT) {
      return <p>お問い合わせ先は準備中です。</p>;
    }

    3行です。「準備中」と正直に出すほうが、動くように見えて届かないより、はるかにましです。

    3. 失敗を隠さない

    設定が正しくても、送信は失敗します。通信が切れる、外部サービスが落ちている、上限に達している。

    このとき、画面をどうするか。「送信しました」と出してしまうのは簡単です。実装が楽ですし、その場では体験が滑らかに見えます。

    しかし結果は2番目と同じで、相手は届いたと思って待ちます。

    失敗したときは失敗したと表示し、時間をおいて試すよう伝える形にしました。気まずさより、事実のほうが役に立ちます。

    3つに共通していたこと

    並べてみると、判断の軸は1つでした。

    避けたかった状態
    うまくいったように見えて、実は何も起きていない

    この状態が厄介なのは、誰も気づけないことです。エラーも出ず、画面も正常で、記録も残りません。問題があること自体が分からないので、直しようがありません。

    失敗するなら、見えるところで失敗させる

    機能が動かないことより、動かないのに動いて見えることのほうが高くつきます。「準備中です」と出ていれば、設定を入れ忘れていることに自分でも気づけます。静かに成功したふりをする実装は、自分自身も欺きます。

    今回の学び

    まず、簡単な方法には、たいてい代償があること。アドレスを直接載せるのは実装ゼロですが、迷惑メールという継続的なコストを払います。手間の総量で比べる必要があります。

    次に、未設定の状態を、設計に含めること。設定が入った状態だけを想定して作ると、入っていない期間の挙動が事故になります。「まだ設定していない」は必ず通る道です。

    そして、成功の表示は、成功したときだけ出すこと。当たり前に聞こえますが、実装を楽にするために崩れやすい原則でもあります。表示は事実に従わせるべきです。

    最後に、静かに失敗する仕組みを作らないこと。これは今回に限らず、繰り返し出てくる主題です。エラーが出る失敗は直せます。直せないのは、失敗したことが分からない失敗のほうです。

  • 1語1ページにしなかった理由

    1語1ページにしなかった理由

    用語集を作ることになりました。記事に出てくる専門用語を、初学者向けに解説するページです。

    問題は入れ物の選び方でした。1語につき1ページか、まとめて1ページか。この判断に、思ったより多くの要素が絡んでいました。

    この記事で扱うこと

    同じ内容でも置き方で使いやすさが変わるという話と、途中で前提が変わったときにどう判断を修正したか。そして「正攻法が使えないと分かったとき」の落としどころの決め方です。

    最初の判断は「1枚にまとめる」だった

    理由は運用でした。用語は50個、100個と増えていくものです。1語ずつ投稿画面を開いて登録する形にすると、そのうち止まります。

    1枚のページに追記していく形なら、思いついたときに1行足すだけで済みます。続けられる形を優先するという判断でした。

    やりたいことが1つ増えて、前提が変わった

    次に出てきたのが「初学者にも分かる解説を加えたい」という考えでした。

    これを入れると、1語あたりの分量が変わります。定義1行では足りず、なぜそれが存在するのか、具体例、つまずきやすい点まで書くことになります。1語400〜800字といったところです。

    単純な掛け算
    102語 × 600字 ≒ 61,000字

    1枚にまとめると6万字を超えます。目当ての語にたどり着くまでスクロールし続けることになり、用語集として使えません。

    前提が変わったら、結論も変わる

    「1枚がいい」という判断は、1語が短いという前提の上に立っていました。深く書くほど、その前提は崩れます。最初の結論に固執せず、条件が変わった時点で見直す必要がありました。

    1語1ページを検討した

    用語ごとにURLを持たせる案には、はっきりした利点があります。

    「WAF とは」で検索した人に、その1語のページを届けられます。1枚にまとめると、ページ全体で1つのURLなので、個々の語では上位に出にくくなります。

    記事から特定の語へ直接リンクすることもできます。

    ところが、逆効果になる可能性があった

    調べていて引っかかったのが、内容の薄いページを大量に作ることのリスクです。

    1語400字のページが100枚あるサイトは、検索エンジンから「中身の薄いページが多いサイト」と見なされる可能性があります。個々の語で上位を狙うつもりが、サイト全体の評価を下げかねません。

    1ページあたりの分量 評価
    1語1ページ 約400〜800字 薄いと判断される可能性
    分野別にまとめる 約7,000〜9,000字 読み物として成立する

    「個別ページのほうが検索に強い」という一般論が、この規模では当てはまらないことが分かりました。

    もうひとつ、実装上の壁があった

    1語1ページにするには、WordPressに専用の投稿タイプを追加する必要があります。ところが調べると、既存の「実績」「スキル」はサーバー上のテーマファイルに直接書かれていました。

    つまり追加するには、本番のPHPファイルを編集することになります。

    本番のPHPを、ブラウザの入力欄から編集しない

    PHPは文法エラーが1文字あるだけで動かなくなります。そしてWordPressの管理画面にも入れなくなります。その状態から戻すにはFTPが必要です。

    「たぶん大丈夫」で触る場所ではありません。復旧手段を確保してから行うべき作業です。

    分野別に8ページ、という落としどころ

    結果として選んだのは、分野ごとに1ページ、全8ページという形です。

    構成
    /glossary/            分野の一覧
    /glossary/infra/      インフラ・ネットワーク  14語
    /glossary/security/   サーバー・セキュリティ  12語
    ...
    

    各ページの先頭に語の索引を置き、それぞれの語には id を振りました。/glossary/security/#waf のように、外部から特定の語を直接指せます。

    1語1ページの利点である「直接リンクできる」は、これでほぼ回収できます。失うのは「その1語だけで検索結果に出る」ことですが、前述の通り、この規模では狙わないほうが安全でした。

    一覧が、それ自体で使える

    索引を置いたことで、ページを開いてすぐ全体を見渡せます。1枚にまとめる案の良さだった「一望できる」は、分野ごとに残っています。

    実装で、新しいものを作らずに済んだ

    もうひとつ判断があります。用語集のページをどう取得するか、です。

    新しい問い合わせを追加すると、このプロジェクトでは型を作り直す必要があります。作り直しにはCMSへの接続が要るので、作業できる環境が限られます。

    ところが既存の問い合わせを見ると、こう書かれていました。

    既にあったもの
    getProfilePage(uri: string = "/profile/")

    URIを引数に取る作りになっていました。プロフィール専用に見えて、実際には「URIから固定ページを引く」という汎用の処理です。

    名前だけを分けて公開し、用語集のURIを渡すことで、問い合わせを1つも増やさずに実装できました。

    名前が用途を狭く見せていた

    getProfilePage という名前のせいで、プロフィール専用のものに見えていました。中身は汎用なのに、名前が可能性を隠していたわけです。

    新しく作る前に、既にあるものが本当に使えないかを見る価値はあります。特に、名前が具体的すぎるものほど確認する意味があります。

    今回の学び

    まず、前提が変わったら結論を見直すこと。「1枚にまとめる」は、1語が短いという前提の上での正解でした。解説を厚くすると決めた時点で、その前提は崩れています。最初の判断を守ることが目的ではありません。

    次に、一般論が自分の規模で成立するかを確かめること。「個別ページのほうが検索に強い」は間違いではありませんが、1ページあたりの中身が薄ければ逆に働きます。条件付きの正解を、無条件の正解として受け取らないことです。

    そして、正攻法が使えないときの落としどころを、妥協と考えないこと。今回は本番のPHPを触れないという制約がありましたが、その制約が無くても分野別を選んだと思います。制約から入って、結果的により良い形にたどり着くことはあります。

    最後に、新しく作る前に、既にあるものを見ること。名前が具体的すぎるだけで、中身は汎用ということがあります。1つ増やさずに済むなら、それだけ壊れる場所が減ります。