タグ: Next.js

  • リンク先は存在した。それでも404だった

    リンク先は存在した。それでも404だった

    連載の記事に「次回:〇〇」というリンクを書いています。そこを押すと404でした。

    リンク切れは事前に確認していました。結果は0件でした。確認の仕方が間違っていたのです。

    この記事で扱うこと

    存在するのに辿り着けないリンクの話です。確認が通ってしまった理由、直し方を3つ比べて1つを選んだ過程、そして表示するときに判定する実装を書きます。

    何を数えていたか

    確認の中身はこうでした。全記事の本文から /blog/ で始まるリンクを抜き出し、その行き先のスラッグがCMSに存在するかを見る。存在しないものがあれば、それがリンク切れです。

    この方法で、112本ぶんを突き合わせて0件でした。

    存在するかどうかを見ていた

    CMSの中には、公開済みの記事と、予約投稿の記事が両方入っています。

    連載の第1回を公開した時点で、第2回はまだ予約投稿の状態です。CMSには在るので、確認は通ります。しかしサイトに出ているのは公開済みのものだけなので、押すと404に着きます。

    2つの集合がずれている

    整理すると、こうなっていました。

    含まれるもの
    CMSにある記事 公開済み + 予約投稿 + 下書き
    サイトに出ている記事 公開済みだけ

    確認していたのは上の集合で、読む人が見ているのは下の集合でした。差の部分が、そのまま404になります。

    書いている側からは見えません。管理画面では第2回が普通に並んでいますし、編集もできます。書き手の視界には、公開前の記事も入っているからです。

    直し方を3つ比べた

    案 やめた理由 / 選んだ理由
    公開のたびに本文からリンクを外す 連載は5本あり、回が進むたびに手を入れる。忘れた回だけ404が残る
    「次回」を書かない 404は消えるが、次があることも伝わらなくなる。連載として損
    表示するときに判定する 採用。書く側は常に「次回」を書ける。公開日が来れば自動でリンクに戻る

    1つめを外したのは、人が覚えていることを前提にした手順だからです。今回そもそも見落としたのは、覚えていることを前提にした確認をしていたからでした。同じ形の対策を足すと、同じ理由で破れます。

    本文を通す場所が、もう1段増えた

    このサイトの本文は、表示する前にいくつかの処理を順番に通ります。商品カードを差し込み、リンクの末尾の形を揃え、見出しに目印を付ける。そこに1段足しました。

    const posts = await getPosts();
    
    // getPosts() は公開済みのものだけを返す。
    // ここに無いスラッグは「CMSには在るが、まだ出ていない」記事になる。
    const publishedSlugs = new Set(
      posts.map((p) => p?.slug).filter((s): s is string => Boolean(s)),
    );
    
    const rawContent = softenPendingLinks(
      normalizeInternalLinks(injectProducts(post.content)),
      publishedSlugs,
    );
    

    判定の材料はもともと手元にありました。前後の記事と関連記事を出すために、記事一覧をすでに取っています。取得の順番を入れ替えただけで、通信は1回も増えていません。

    リンクを消さずに、文字にした

    return html.replace(POST_LINK, (whole, _before, slug, _after, text) => {
      if (publishedSlugs.has(slug)) return whole;
      return `<span class="pending-link">${text}<span class="pending-link-note">(近日公開)</span></span>`;
    });
    

    行き先が無いなら丸ごと消す、という手もありました。選ばなかったのは、予告そのものには意味があるからです。

    押せないことと、無いことは違う

    「次回:打ち方が違っても、同じ言葉として扱う」という一文は、続きがあることと、その内容を伝えています。押せるかどうかとは別の仕事です。

    消すと、その情報まで一緒に消えます。薄い文字にして「(近日公開)」を添えれば、押せないことは分かり、何が来るかは残ります。

    「(近日公開)」をCSSの装飾ではなく本文の一部として出しているのは、読み上げや、文字を選んでコピーしたときに落ちないようにするためです。押せない理由は、飾りではなく中身として持たせることにしました。

    置き換えで壊しそうな場所

    本文のHTMLを書き換える処理は、意図しないものまで巻き込みます。6つ試しました。

    試したこと 期待
    公開済みへのリンク そのまま残る
    1つの行に「前回」と「次回」が並ぶ 前回は残り、次回だけ文字になる
    class や title が前後に付く 拾える
    連載の目次(/series/)へのリンク 触らない
    外部サイトのURL 触らない
    リンクの文字に <strong> が入る 中のタグを保つ

    5つめは見落としやすい形をしています。外部のURLにも /blog/ という並びが現れることがあるので、行き先がサイト内かどうかを先に判定する必要がありました。

    確認そのものを直す

    今回いちばん効いたのは、コードの修正ではありません。確認の条件を書き直したことです。

    見ていたもの
    前 リンク先のスラッグが、CMSに存在するか
    後 リンク先のスラッグが、公開済みの一覧に在るか

    条件を書き直して数え直したら、公開中の57本のうち、公開前を指しているリンクは1件でした。指摘を受けたその1件です。数が合ったことで、見落としの範囲が確定しました。

    公開日が来たら、本当に戻るのか

    「公開日が来れば自動でリンクに戻る」と書きましたが、これは作った側の見込みでしかありません。確かめる機会は、2日後に来ました。

    第2回の公開日に、第1回と第2回を両方開いて、連載の案内を見比べました。

    記事 「次回」の行き先 表示
    第1回 第2回(その日に公開) リンクに戻っていた。押すと開く
    第2回 第3回(まだ予約) 文字のまま、「(近日公開)」付き

    第1回の「次回」は、2日前まで押せない文字でした。その日の朝、誰も何も触らないまま押せるリンクになっていて、代わりに第2回の「次回」が押せない文字になっていました。1本ずつ、順送りに開いていく形です。

    戻ることまで確かめて、はじめて直ったと言える

    押せなくする側だけを見て終わりにしていたら、「公開後もずっと押せないまま」という別の壊れ方に気づけませんでした。

    表示を止める仕組みは、止めたものを再開させる側まで確認してようやく半分ではなくなります。

    今回の学び

    まず、存在することと、辿り着けることは別だということ。手元のデータで「在る」と確認できても、読む人に配っている集合に入っていなければ意味がありません。確認は、読む人が見るほうの集合でやる必要がありました。

    次に、書き手の視界は広すぎること。管理画面には公開前のものも並びます。そこを基準に考えると、自分にだけ見えているものを、全員に見えていると錯覚します。

    そして、人が覚えていることを前提にした対策を足さないこと。見落としの原因が「覚えていること前提の確認」だったのに、対策も同じ形にしたら、同じ理由で破れます。表示するときに判定する形にしたのは、そこを繰り返さないためでした。

  • 動かしていなかったから、漏れていなかっただけ

    動かしていなかったから、漏れていなかっただけ

    問い合わせフォームにボット対策を入れようとして、先に別のことが分かりました。

    いまの作りでは、ブラウザ側にどんな対策を入れても効きません。

    この記事で扱うこと

    送信先のURLが配信されるJavaScriptに入る作りになっていた話と、その直し方です。あわせて、画像の文字を読ませる方式を選ばなかった理由も書きます。

    先頭の6文字が、置き場所を決めていた

    このサイトはNext.jsで作っています。環境変数の名前には、決まりがあります。

    名前 どこから見えるか
    CONTACT_ENDPOINT サーバーだけ
    NEXT_PUBLIC_CONTACT_ENDPOINT ブラウザにも配られる

    名前の先頭に NEXT_PUBLIC_ が付いていると、その値は組み立てのときにJavaScriptの中へ埋め込まれます。訪問者に配られるファイルの一部になる、ということです。

    フォームはブラウザの中で動く部品なので、送信先を知る必要がありました。だから素直に NEXT_PUBLIC_ を付けていました。付けなければ読めないので、それ以外に選びようが無いように見えていました。

    鍵の掛かっていない扉が、隣に並ぶ

    この形だと、送信先のURLはフォームを開かなくても分かります。配られたファイルを開けば書いてあります。

    フォームを通さずに送れる

    見えない入力欄を足しても、送信までの時間を測っても、それらはフォームを使った場合にだけ効きます。

    送信先を直接叩く相手には、そもそも通り道が違うので関係がありません。鍵を掛けた扉の横に、鍵の掛かっていない扉が並んでいる状態でした。

    動かしていなかったから、漏れていなかった

    ここで確認したところ、本番の環境変数は設定されていませんでした。

    フォームは、送信先が無いときは「お問い合わせ先は準備中です」と出す作りにしてあります。つまり公開されてからずっと、フォームは表示されておらず、URLもどこにも出ていませんでした。

    安全だったのではなく、使っていなかっただけ

    結果として何も漏れていませんが、設定を入れた瞬間に漏れる形のまま置いてありました。

    この種の問題は「まだ起きていない」と「起きない」の区別が付きにくく、動かし始めた日にまとめて効いてきます。

    ついでに言うと、フォームが出ていなかったこと自体も問題でした。問い合わせ先として案内していた場所が、ずっと準備中だったということになります。

    自分のサイトに窓口を作る

    直し方は、間に1枚挟むことでした。

    前 後
    送信先 ブラウザ → 外部サービス ブラウザ → 自分のサイト → 外部サービス
    URLの在り処 配られるJavaScript サーバーだけが持つ
    判定の場所 ブラウザ(迂回できる) サーバー(迂回できない)

    ブラウザから見えるのは /api/contact だけになります。ここを直接叩くことはできますが、叩いた先で何を見ているかは分かりません。

    画像の文字を読ませる方式は、入れなかった

    ボット対策というと、ゆがんだ文字や、信号機の写真を選ばせるものが思い浮かびます。今回は入れていません。

    読みにくい文字は、機械より先に人が脱落する

    問い合わせは、そもそも件数が多くありません。1日に何百件も来るものではない。

    それに対して、1件取りこぼす損のほうが大きいと考えました。迷惑な送信を100件止める価値より、相談しようとした人が1人あきらめる損のほうが重い、という判断です。

    人がやらないことを、3つ見る

    代わりに置いたのは、訪問者の手間が一切増えない3つです。

    見ること 人がやらない理由
    見えない欄が埋まっている 画面に出ていないので、触りようがない
    開いてすぐ送っている 3秒で文章は書けない
    短時間に何度も送っている 同じ相談を10分に4回はしない

    どれも、正しく使っている人には何も起きません。止めるために何かをさせるのではなく、やっていないことを確認する形にしています。

    見えない欄の名前を、慎重に決めた

    ひとつだけ、注意が必要な部分があります。見えない欄の名前です。

    この欄を name や url のような一般的な名前にすると、ブラウザやパスワード管理ソフトの自動入力が、画面に出ていなくても勝手に埋めることがあります。

    人の送信が、黙って捨てられる

    この欄が埋まっている送信は、機械とみなして捨てます。しかも成功したように見せて捨てます。理由を返すと、次からその欄を避ければ通ると分かってしまうからです。

    つまり自動入力に埋められた人は、送信したつもりで、何も届いていない状態になります。これがこの仕組みの一番の失敗です。

    自動入力が狙わない名前にしたうえで、キーボードの移動先からも読み上げの対象からも外しています。

    回数の数え方には、限界がある

    正直に書いておくと、短時間の回数を数える部分は完全ではありません。

    このサイトのサーバー側の処理は、必要なときに起動して、しばらくすると消えます。数えた記録は共有されず、消えるときに一緒に無くなります。厳密には数えられません。

    それでも、続けて叩いてくる相手は同じ実体に当たりやすいので、一番困る「短時間に何百件」は止まります。正確さが要るようになったら、外に記録を置くことになります。いまの流量でそこまで持つ必要は無いと判断しました。

    今回の学び

    まず、名前の先頭6文字が、置き場所を決めていたということ。NEXT_PUBLIC_ は便利な省略ではなく、「これは配る」という宣言でした。付けなければ読めないから付ける、では順番が逆でした。

    次に、ブラウザ側の判定は、通り道が1本のときにだけ効くこと。もう1本、鍵の掛からない道が並んでいれば、そちらが使われます。対策を足す前に、道が何本あるかを数える必要がありました。

    そして、止めるために手間を増やさないこと。問い合わせのように件数が少ないものでは、止めた数より取りこぼした数のほうが重くなります。人がやらないことを確認するだけなら、正しく使う人には何も起きませんでした。

  • あと34本で、記事が消え始める作りだった

    あと34本で、記事が消え始める作りだった

    サイト全体を見直していて、あと34本で記事が消え始める作りになっていることに気づきました。

    エラーは出ません。ビルドも通ります。ただ、書いた記事が一覧にもサイトマップにも出なくなります。

    この記事で扱うこと

    件数の上限を置いたまま忘れると何が起きるか。なぜ気づけないのか。そして上限を上げるのではなく、外した話です。

    1行だけの問題だった

    CMSへの問い合わせに、こう書いてありました。

    posts(first: 100) {

    「最初の100本を取ってくる」という意味です。書いた当時は記事が10本もなく、100は十分に大きい数でした。

    いま66本あります。残り34本。

    101本目に何が起きるか

    ここが厄介なところです。何も起きません。

    確かめる場所 101本目を書いたあと
    CMSの管理画面 記事はある。公開済みになっている
    サイトの表示 正常。崩れない
    ビルド 成功する
    エラーの通知 来ない
    一覧・サイトマップ その記事だけ載らない

    気づく手段が、「あれ、あの記事どこへ行った」と思うことだけです。しかも消えるのは一番古い記事か新しい記事かも、並び順次第で変わります。

    上限を書いた本人は、上限のことを覚えていない

    この行を書いたのは半年近く前で、そのときは「100もあれば当分平気」と思って書いています。実際そのとおりでした。

    問題は、平気な期間が長いことです。長く問題が出ないと、その数字があること自体を忘れます。そして忘れたころに到達します。

    上げるか、外すか

    いちばん簡単なのは、100を500にすることです。1文字の修正で済みます。

    ただ、それだと同じ壊れ方が先に延びるだけです。500本書いたときに、同じように何も知らせずに消え始めます。そのときの自分は、いまより確実に忘れています。

    そこで、区切りそのものを外しました。

    posts(first: 100, after: 前回の続きの位置) {
      pageInfo { hasNextPage endCursor }
      ...
    }

    100本ずつ取って、「まだ続きがある」と言われているあいだは取りに行き続けます。記事が何本になっても、全部取れます。

    念のための打ち切りは、別の理由で置いた

    実は上限を1つ残しています。30回まで、という繰り返しの回数です。

    これは記事数のためではありません。相手が壊れていて「まだある」と言い続けたときに、公開作業が終わらなくなるのを防ぐためです。100本ずつなので、ここに達するのは3000本を超えてからになります。

    同じ「上限」でも、普通に使っていて到達するものと、異常時にしか到達しないものは別ものです。

    同じ数字が、もう1か所にあった

    直したあと、他にも同じ書き方が無いか探しました。サイトマップを作る処理にも、同じ100が入っていました。

    こちらのほうが影響は大きい。一覧に出ないだけなら内部リンクからは辿れますが、サイトマップから漏れると検索エンジンに存在を知らせる手段が減ります。

    両方とも同じ形に直しました。

    気づけたのは、数えたから

    この問題は、使っていても出てきません。見つかったのは、サイト全体を機械的に確かめていたときでした。

    やったのは単純で、数を数えることです。記事が何本あるか、サイトマップに何件載っているか、一覧に何枚並んでいるか。

    数えたもの 分かったこと
    記事の本数(66) 上限100まであと34本
    一覧のHTMLの大きさ 202KB。ほとんどが画面外のカード
    代替テキストの有無 62枚中21枚が未設定で、混ざっていた

    どれも見た目には出ていません。数えないかぎり、全部「問題なし」に見えます。

    今回の学び

    まず、「当分平気な数字」は、平気なあいだに忘れられるということ。書いた時点では正しい判断でしたが、正しさが長く続くほど、その存在を思い出すきっかけが無くなります。

    次に、上限を上げるのは、同じ壊れ方を先送りにするだけだということ。500にしても、500本目に同じことが起きます。そのときの自分が覚えている見込みは、いまより低い。

    そして、使っていて気づけない問題は、数えるしかないこと。表示は正常、ビルドも成功、エラーも無し。この状態で問題を見つける手段は、動かすことではなく数えることでした。

  • Criticalが4件並んだ日にやったこと

    Criticalが4件並んだ日にやったこと

    朝、通知を見たらCriticalが4件並んでいました。どれも同じ相手、Next.js本体です。

    この日にやったことを順に書きます。結論から言うと、上げました。ただ、上げる前にやったことのほうが本題です。

    この記事で扱うこと

    脆弱性の通知が来たときに、何をどの順で確かめたか。自分のサイトが実際に影響を受けるのかを切り分ける手順と、それでも上げた理由です。

    まず、中身を読む

    4件は、実は2種類でした。同じものが2つのファイルで検出されているので、件数が倍に見えていました。使っているものを書いた一覧と、実際に入った版を固定した一覧の2つです。

    種類 内容
    A Windowsで動かしているサーバーでの、認証不要の遠隔コード実行
    B 画像最適化の機能で、AVIF形式を扱ったときの遠隔コード実行

    どちらも「認証不要」で「遠隔からコードを実行される」。言葉としては、これ以上ないくらい重い部類です。

    Aは、条件に当てはまらなかった

    Aの説明には、条件がはっきり書いてありました。WindowsのファイルシステムでNext.jsのサーバーが動いている場合に成立する、と。

    そして、LinuxとmacOSは影響を受けないと明記されていました。

    このサイトの本番はVercelで動いています。Linuxです。開発しているのもMacです。どちらも対象外でした。

    「Critical」は自分への影響度ではない

    重大度は、その脆弱性が最悪の場合にどれだけひどいかを表しています。自分の環境で成立するかどうかは、また別です。

    色と数字だけ見て慌てると、条件を読まずに動くことになります。読めば1分でした。

    Bは、入口の広さを自分で確かめた

    Bのほうは、条件が「AVIF形式の画像を最適化したとき」です。攻撃者が用意した画像を最適化させられると成立します。

    このサイトは画像の最適化機能を使っています。ここで確かめるのは、「誰でも好きな画像を最適化させられる状態か」です。

    設定を見ると、最適化を許可している場所が2つだけ指定してありました。

    remotePatterns: [
      { hostname: "localhost", port: "8080", ... },
      { hostname: "cms.kisaku.site",
        pathname: "/wp/wp-content/uploads/**" },
    ]

    つまり、CMSの画像置き場にあるファイルしか最適化できません。任意のURLを渡しても弾かれます。

    そこに置くには、CMSにログインして画像をアップロードする必要があります。入口はかなり狭いと言えます。

    「狭い」は「無い」ではない

    ここで止めたくなりますが、狭いだけで塞がってはいません。

    CMSのログインが破られれば成立します。そしてCMSは共用サーバー上のWordPressで、攻撃対象としてはありふれた構成です。「入口が狭いから大丈夫」は、入口が破られない前提に乗っています。

    結局、上げた

    Aは対象外、Bは入口が狭い。それでも上げました。

    理由は単純で、上げない理由のほうが薄かったからです。

    上げる 上げない
    作業量 修正版への更新1回 0
    壊れる可能性 細かい版の更新なので低い —
    放置した場合 — 入口が破られた時点で成立する

    大きな作り替えを伴う更新なら、慎重になる理由があります。今回は末尾の数字が1つ上がるだけの修正版で、その中身も「問題のある機能を止める」というものでした。

    本番に入るものと、そうでないものを分けて数える

    更新したあと、残りを確かめました。ここで1つ、見方を変えています。

    そのまま数えると12件残っていました。ところが「本番に配るものだけ」で数え直すと0件です。

    npm audit --omit=dev
    found 0 vulnerabilities

    残っていた12件は、全部開発中にしか動かない道具のものでした。型を生成するツールが使っている、さらにその中のライブラリです。

    配られないものは、訪問者に届かない

    これらはビルドのときに自分のマシンで動くだけで、配信されるファイルには入りません。訪問者のブラウザにも、サーバーにも届きません。

    0件にはしたいのですが、直すには型生成ツールを大きく更新する必要があり、そちらのほうが壊れる可能性が高い。今回は見送って、理由を書き残しました。

    数字を0にすることが目的になると、危険度の低いもののために動いているものを壊します。分けて数えると、どちらを急ぐかが決まります。

    やったことをまとめると

    順番 やったこと かかった時間
    1 4件が実は2種類だと確認 1分
    2 条件を読んで、対象外を1つ外した 2分
    3 残りの入口の広さを設定で確かめた 5分
    4 更新して、型検査を通した 5分
    5 本番に入るものだけで数え直した 1分

    手を動かしたのは4だけで、残りは読んで確かめる時間でした。

    今回の学び

    まず、重大度は「最悪の場合」の話であって、自分への影響ではないということ。条件が書いてあるので、読めば当てはまるかどうかが分かります。今回は片方が最初から対象外でした。

    次に、「入口が狭い」で止めないこと。狭いのは事実でしたが、狭さはこちらの設定に依存しています。設定を変えた日に広がるかもしれません。狭いことを理由に放置すると、その理由を覚えている人がいなくなった時点で危なくなります。

    そして、数える範囲を先に決めること。全部で12件と、配信されるものの中で0件では、判断がまったく変わります。件数を減らすこと自体を目的にすると、動いているものを壊しにいくことになります。

  • 印を付けただけでは、探せるようにならなかった

    印を付けただけでは、探せるようにならなかった

    記事一覧のカードには、前から「読了済み」の印を付けていました。

    付けた当時は57本でした。いまは110本を超えています。印は役に立たなくなっていました。

    この記事で扱うこと

    読了の印だけでは足りず、未読だけを出す絞り込みを足した話です。URLに入れなかった理由、読んだ人にだけボタンを出した理由、そして0件の見せ方を書きます。

    見分けることと、探すことは違う

    印が付いていれば、目の前のカードが既読かどうかは分かります。見分けることはできます。

    けれど「まだ読んでいないものを見たい」ときにやることは、100枚のカードを上から順に見て、印の付いていないものを拾う作業になります。これは探す作業で、印はそれを助けていません。

    それは一覧の仕事だった

    並んでいるものから条件に合うものを取り出すのは、一覧の側がやることです。

    印だけ出して残りを目でやらせるのは、判定の材料は渡したが、判定はそちらでやってくれと言っているのと同じでした。

    URLには入れなかった

    この一覧には、すでにカテゴリとタグの絞り込みがあります。どちらも /blog?category=tech のようにURLに入ります。

    未読の絞り込みは、同じ形にしませんでした。

    絞り込み 誰にとっても同じか URLに入れるか
    カテゴリ・タグ 同じ。誰が開いても同じ記事が出る 入れる
    未読 人によって違う 入れない

    URLに入れるということは、その状態を他の人に渡せるということです。カテゴリなら渡せます。「技術系の記事はここ」と共有できます。

    未読は渡せません。渡された側にとっては、自分と関係のない条件で記事が消えている一覧になります。共有したつもりの人も、渡された人も、何が起きたか分かりません。

    読んだことがある人にだけ出す

    ボタンは、1本でも読んだ記録がある人にだけ出しています。

    初めて来た人にとっては、全部が未読です。押しても何も変わりません。

    効かないものが置いてあると、その場所ごと見なくなる

    押して何も起きない経験を一度すると、そのボタンは「効かないもの」として記憶されます。

    後日ちゃんと効く状態になっても、もう押されません。意味を持つまで出さないほうが、意味を持ったときに使われます。

    0件を、失敗のように見せない

    未読だけにして0件になる状態は、全部読み終えたという意味です。良い状態です。

    ところが検索の0件と同じ文面を出すと、何かを間違えたように見えます。そこで分けました。

    状況 出す文面
    検索で0件 「〜に当てはまる記事はありませんでした」+用語集への案内
    未読が0件 「記事はすべて読み終えています」+絞り込みを解除するボタン

    解除のボタンを添えているのは、行き止まりにしないためです。0件の画面で、絞り込みを外す手段がその場に無いと、戻り方を探すことになります。

    戻ってきたときに、数が合わない

    実装して最初に気になったのが、ここでした。

    一覧から記事を開いて読み、ブラウザの「戻る」で一覧に帰ってくる。このときページは作り直されません。離れたときの画面がそのまま出てきます。

    いま読んだ記事が、未読のまま残る

    読了の記録は更新されているのに、一覧は古いままです。「未読だけ」と書いてあるのに、たったいま読んだ記事が並びます。

    戻ってきたときに発生する合図(pageshow)で読み直すようにしました。結果として、戻った瞬間にいま読んだ記事が一覧から消えます。目の前で消えるのは少し驚きますが、「未読だけ」と書いてある以上、消えるほうが正しいと判断しました。

    読了の記録は、こちらには届いていない

    念のため書いておくと、どの記事を読んだかという記録はその人のブラウザの中にだけあります。こちらのサーバーには送っていませんし、見ることもできません。

    だからこの絞り込みは、サーバー側では掛けられません。ブラウザ側でやっているのはそのためです。

    今回の学び

    まず、材料を出すことと、仕事をすることは違うということ。印は判定の材料でしたが、判定そのものは読む人に残っていました。数が増えると、その差がそのまま手間になりました。

    次に、渡せる条件と渡せない条件があること。URLに入れるかどうかは、実装の都合ではなく「他の人に渡して意味があるか」で決まりました。

    そして、0件には種類があること。見つからなかった0件と、やり終えた0件は別のものです。同じ文面で済ませると、良い知らせが悪い知らせに見えます。

  • 再試行は入れていた。それでもデプロイが落ちた

    再試行は入れていた。それでもデプロイが落ちた

    再試行の処理は、半年前から入れていました。CMSが500を返したら、少し待ってもう一度投げる。3回まで。

    それでも、デプロイが落ちました。

    この記事で扱うこと

    再試行を入れてあったのに効かなかった話です。何が返ってきていたのか、なぜ3回では足りなかったのか、そして回数ではなく間隔を増やした理由を書きます。

    ページを先に作る構成では、ビルド中に問い合わせが集中する

    このサイトは、訪問者が来てからCMSに聞くのではなく、デプロイのときに全ページを作ってしまう構成です。表示は速くなりますが、そのぶんビルドの最中にCMSへ何十回も問い合わせが飛びます。

    記事が増えたいまは、1回のビルドで82ページぶんの問い合わせが、1分足らずのあいだに走ります。

    失敗の意味が変わっていた

    動的に表示していたころは、1回の問い合わせが失敗してもその1回の表示が失敗するだけでした。次に開けば直ります。

    事前生成に変えたあとは、同じ1回の失敗がデプロイ全体を止めます。新しい記事も、直したところも、まとめて出なくなります。

    速くするために取った選択が、別の場所の壊れやすさを生んでいたことになります。

    返ってきていたのは、WordPressのエラーではなかった

    ビルドのログを見ると、82ページ中40ページを作ったあたりで止まっていました。

    Error: GraphQL Error (Code: 500): {"response":{"error":"<!DOCTYPE HTML PUBLIC ...
    

    500という数字だけ見ると「WordPressが処理に失敗した」と読みたくなりますが、中身はHTMLでした。しかもWordPressのエラー画面ではなく、Apacheが出す素のエラーページです。

    返ってきたもの そこから分かること
    WordPressのエラー(JSON) WordPressまでは届いている。中で失敗した
    Apacheの素のエラーページ WordPressに届く前に断られている

    つまりPHPが動く手前の段階で弾かれていました。短時間に問い合わせが集中したことで、共用サーバー側がまとめて断る状態に入っていたのだと思います。

    3回では足りなかった理由

    入れてあった再試行は、こういう設定でした。

    回 待ち時間 累計
    1回目 — 0秒
    2回目 1秒 1秒
    3回目 3秒 4秒

    最初から最後まで、4秒で終わります。

    相手が「一瞬もたついた」だけなら、これで通ります。実際これまでは通っていました。今回そうならなかったのは、断られている状態が数秒では明けなかったからです。

    同じ壁に3回ぶつかっていた

    再試行は動いていました。ログにも3回ぶんの失敗が残ります。

    ただ、4秒のあいだに3回投げても、3回とも同じ状態の相手に当たります。回数を増やしても、待っている時間が短ければ結果は変わりません。

    回数ではなく、間隔を増やした

    直したのは、待ち時間の表です。

    const MAX_ATTEMPTS = 5;
    const RETRY_DELAY_MS = [1000, 3000, 8000, 15000];
    

    最後まで粘ると、1件あたり最長27秒かかります。ビルドは確実に遅くなります。

    それでもこちらを選んだのは、待って通るほうが、デプロイごと落ちるより安いからです。27秒は待てば終わりますが、落ちたビルドは自分では直りません。気づいて、原因を調べて、押し直すまで止まったままになります。

    なぜ回数を増やすだけでは足りないか

    回数だけ10回に増やしても、間隔が1秒なら全体で10秒です。混んでいる相手にとってはさらに叩きに来ただけになります。

    相手が回復するのを待つのが目的なので、増やすべきは回数ではなく間隔のほうでした。

    気づくのが遅れた理由

    この失敗に気づいたのは、デプロイした数時間後でした。新しく足したページを開いたら404だったからです。

    ビルドが失敗しても、サイトは前の版のまま動き続けます。これ自体は正しい挙動で、壊れた版が公開されるよりずっと良い。ただし見ている側には何も起きません。止まったことは、止まった画面を見ても分からないということです。

    いまは通知を入れていませんが、次に同じことが起きたときに数時間気づかない状態は残っています。ここは別に手を打つ必要があると考えています。

    今回の学び

    まず、速くする変更が、壊れやすさを持ち込むことがあるということ。事前生成そのものは良い選択でしたが、「1回の失敗が何を止めるか」が変わっていました。変えたときに、失敗の重さも一緒に変わります。

    次に、500の中身を見ないと、誰が断ったか分からないこと。同じ500でも、アプリが失敗したのか、その手前で断られたのかで打つ手が違います。今回は中身がHTMLだったことが手がかりになりました。

    そして、再試行は「回数」ではなく「どれだけ待つか」だということ。入れてあること自体は安心材料になりません。相手が何秒で回復するのかを考えないまま回数だけ決めていた、というのが本当の原因でした。

  • ソース解説(5) 文字列を自分で組み立てる場所

    ソース解説(5) 文字列を自分で組み立てる場所

    このサイトで、HTMLを文字列として自分で組み立てている場所は2つあります。RSSフィードとサイトマップです。

    画面に出るページは、部品を組み合わせれば勝手に出来上がります。この2つは違います。1行ずつ並べて、最後に文字列としてつなぎます。

    この記事で扱うこと

    自分で組み立てる場所に何が必要になるか。具体的には、エスケープ、日付の形式、そして省略していい項目とそうでない項目の見分け方です。

    1文字でフィード全体が壊れる

    まずここから。記事のタイトルに「&」や「<」が入ることは普通にあります。

    順番 元の文字 書き出す形
    1 & &amp;
    2 < &lt;
    3 > &gt;
    4 二重引用符 &quot;
    5 単引用符 &apos;

    これを通さずにタイトルを書き込むと、XMLとして読めなくなります。

    壊れ方が「1記事だけ出ない」ではない

    HTMLは多少おかしくてもブラウザが解釈してくれますが、XMLは1か所でも壊れると全体が読めません。

    つまり、タイトルに「&」が入った記事を1本出した瞬間に、購読アプリ側では何も表示されなくなります。その1本だけが抜けるのではありません。

    そして & の置き換えがいちばん最初にあるのが重要です。後ろに回すと、< を &lt; に変えた後の & をもう一度変換してしまい、&amp;lt; になります。

    日付の名前を、自分で持っている

    RSSが定める日付は「Sun, 24 Aug 2026 13:27:40 GMT」の形です。英語の曜日と月名を使います。

    const DAYS = ["Sun", "Mon", "Tue", ...];
    const MONTHS = ["Jan", "Feb", "Mar", ...];

    日付を文字にする機能はブラウザにもありますが、結果が端末の言語設定に左右されます。日本語の環境で「日」「8月」と出てしまうと、フィードとしては読めません。

    形式が決まっているものは、こちらで名前を持っているほうが確実でした。

    時間帯の表記が無い文字列

    WordPressから届く日付は「2026-08-24T13:27:40」の形です。末尾に時間帯の表記がありません。

    const iso = HAS_TIMEZONE.test(gmt)
      ? gmt
      : gmt + "Z";

    すでに時間帯が付いているかを確かめて、無ければ Z(世界標準時)を足してから解釈します。

    HAS_TIMEZONE は、末尾に時間帯の表記があるかを判定する正規表現です。実物は1行に書いてありますが、長くて読む邪魔になるので、ここでは名前を付けて抜き出しています。

    足さないと、見る人の環境で日付がずれる

    時間帯の表記が無い文字列は、解釈する側の地域として扱われることがあります。日本で動かすと9時間ずれた値になります。

    表示は普通に出ます。「少し前の日付だな」と思うだけで、ずれていることに気づけません。

    無い項目は、書かない

    組み立てる側でいちばん判断が要るのがここでした。

    pubDate ? "<pubDate>" + pubDate + "</pubDate>" : null,
    summary ? "<description>" + summary + "</description>" : null,
    

    そして最後に、null の行を落としてからつなぎます。

    .filter((line): line is string => line !== null)
    .join("\n")

    日付や説明が取れなかったとき、空のまま項目だけ書くこともできます。そうしないのは、空の項目が「値が無い」ではなく「空という値がある」と読まれるからです。

    サイトマップ側でも同じ判断をしています。

    if (post.modified) {
      entry.lastModified = new Date(post.modified);
    }
    無いときに今日の日付を入れてはいけない

    更新日が取れないときに今日の日付を埋めると、「毎日更新されている」と伝えることになります。

    形としては揃いますが、内容は嘘です。何度か見に来られて、毎回変わっていないと分かれば、そのうち信用されなくなります。書かないほうが正確でした。

    更新日時に、生成時刻を使わない

    フィード全体の更新日時も同じ考え方です。

    const lastBuildDate = toRfc822(posts[0]?.dateGmt);

    作った瞬間の時刻ではなく、いちばん新しい記事の日付を使っています。

    生成時刻にすると、中身がまったく変わっていなくても毎回新しく見えます。フィードは1時間ごとに作り直しているので、記事を1本も書いていない日でも「24回更新された」ことになります。

    作り直す間隔を、用途で分けている

    このサイトには、作り直す間隔が3種類あります。

    対象 間隔 理由
    記事ページ・一覧 5分 読む人が直接見る
    RSS 1時間 購読アプリ側も数十分おきにしか来ない
    サイトマップ 1時間 載っているかだけが問題

    RSSを5分にしても、届く速さは変わりません。相手が見に来る間隔のほうが長いからです。短くして得があるのは、読む人が直接開くページだけでした。

    サイトマップは、XMLを書いていない

    最後に1つ。フィードは文字列を組み立てていますが、サイトマップは違います。

    export default async function sitemap():
      Promise<MetadataRoute.Sitemap> {
      return [...staticEntries, ...glossaryEntries, ...postEntries];
    }

    配列を返すだけで、XMLは自動で作られます。RSSに同じ仕組みが無いので、そちらだけ手で書いています。

    手で書ける場所では、手で書かない。エスケープも日付の形式も、自分で持てば自分で間違えられるようになります。

    今回の学び

    まず、厳しい形式ほど、壊れ方が全か無かになるということ。XMLは1文字で全体が読めなくなります。多少おかしくても表示されるHTMLに慣れていると、この差は見落とします。

    次に、値が無いときに、形を揃えるために埋めないこと。更新日が無いのに今日の日付を入れると、形は整いますが意味が変わります。空欄のまま出すか、項目ごと書かないかを選ぶほうが正確でした。

    そして、間隔は相手の都合で決めること。購読アプリが1時間おきにしか来ないなら、こちらが5分ごとに作り直しても誰も早く受け取れません。速くする対象を選ぶのも、相手が誰かを見てからでした。

  • ソース解説(4) 速くしたら、デプロイが止まった

    ソース解説(4) 速くしたら、デプロイが止まった

    記事のデータは、共用サーバー上のWordPressから取ってきています。この部分は2回作り直しました。

    最初は「常に最新を取る」形。速度の問題が出て、キャッシュする形へ。すると今度は、デプロイが止まるようになりました。

    この記事で扱うこと

    問い合わせ部分のコードと、キャッシュを置いた場所、そして相手が「たまに失敗する」前提で書き直した話です。速くするための選択が、別の場所の壊れやすさを生んでいました。

    最初の形と、その代償

    以前は、問い合わせのたびに「保存しないで取ってきて」と指定していました。記事を公開したらすぐ反映される、という点では狙いどおりです。

    ただしこの指定があると、ページをあらかじめ組み立てておくことができません。訪問者が来るたびに共用サーバーへ問い合わせが飛び、その待ち時間がそのまま表示速度になります。

    実測で、最初の1バイトが返るまで約1.7秒でした。

    キャッシュを、どこに置くか

    ここで1つ引っかかりました。

    Next.jsには、通信の結果を保存しておく仕組みがあります。ところがそれが効くのはGETで取りに行ったときだけです。GraphQLの問い合わせはPOSTで送るので、通信のところに保存の指定を書いても素通りします。

    書いてあるのに効かない、がいちばん厄介

    指定を書いた側から見ると、設定はしてあります。エラーも警告も出ません。速くなっていないことでしか気づけません。

    「効くはずの場所に書いたのに効かない」ときは、その仕組みが何を対象にしているかを読み直す必要がありました。

    そこで、通信ではなく「関数の戻り値」を保存する仕組みに切り替えました。

    export const getPosts = unstable_cache(
      async () => {
        const data = await graphqlClient.request(GET_POSTS);
        return data.posts?.nodes ?? [];
      },
      ["posts"],
      { revalidate: 300, tags: ["wordpress"] },
    );

    これは通信の種類に関係なく効きます。引数も自動で鍵に含まれるので、getPostBySlug("a") と getPostBySlug("b") は別物として保存されます。

    末尾の ?? [] は「取れなかったら空の配列」。件数が0件なのと、取得に失敗したのを同じ形で返しています。一覧を表示する側から見れば、どちらも「並べるものが無い」なので、扱いを分ける意味がありません。

    タグは、まだ使っていない

    tags: ["wordpress"] は、あとから「この印の付いた保存を今すぐ捨てる」ためのものです。

    WordPressで記事を公開したときにVercelへ通知を送る仕組みを足せば、5分待たずに反映できます。いまは使っていませんが、後から足せるように印だけ置いています。後で足すときにデータの形を変えずに済みます。

    速くしたら、デプロイが止まった

    ここからが本題です。

    ページをあらかじめ組み立てるということは、組み立てるタイミングでCMSへ問い合わせに行くということです。訪問者が来たときではなく、公開作業の途中で。

    共用サーバーのWordPressは、混み具合によっては処理しきれずエラーを返すことがあります。以前は動的に組み立てていたので、そのときは「その1回の表示が失敗する」だけで済んでいました。

    ところが事前に組み立てる形にしたことで、1回の失敗がデプロイ全体を止めるようになりました。実際に止まりました。

    速さと引き換えに、壊れる場所が移動した

    遅いが止まらない形から、速いが止まる形へ移っていました。速度の話をしているつもりで、失敗したときに何が起きるかを一緒に変えていたことになります。

    失敗する前提で書き直す

    対処として、投げ直す処理を入れました。

    const MAX_ATTEMPTS = 3;
    const RETRY_DELAY_MS = [1000, 3000];

    待ち時間を置いているのは、すぐ投げ直しても、混んでいる相手には効かないからです。同じ瞬間にもう一度叩くのは、混雑を増やしているだけになります。

    そして、投げ直す相手を選んでいます。

    if (response.status >= 500 && attempt < MAX_ATTEMPTS - 1) {
      continue;
    }
    return response;
    返ってきたもの 意味 扱い
    500番台 相手側の不調 投げ直す
    400番台 こちらの要求が違う 投げ直さない
    通信そのものの失敗 届いていない 投げ直す

    400番台を投げ直さないのは、何度試しても同じ結果になるからです。要求の中身が間違っているのに3回投げるのは、単に3倍待つだけです。

    取れなかったときに、何を返すか

    RSSとサイトマップでは、さらに一段踏み込んでいます。

    let posts = [];
    try {
      posts = await getPostsForFeed();
    } catch (error) {
      console.warn("[feed] 記事一覧を取得できませんでした", error);
    }

    失敗しても、そのまま先へ進みます。記事が0件のフィードになりますが、フィードとしては成立する形で返ります。

    壊れているように見せないほうがいい場面がある

    ここで例外をそのまま外へ出すと、そのページ自体がエラーになります。購読アプリによっては「壊れている」と判断して購読を外します。

    一時的な不調のせいで、購読を失うのは割に合いません。中身が薄いものを返すほうが、被害が小さく済みます。

    サイトマップも同じで、記事が取れなければ固定ページの分だけを返します。不完全なサイトマップは、存在しないサイトマップよりは役に立ちます。

    問い合わせの道具を、あえて小さいものにした

    GraphQLの問い合わせには、高機能なものも使えます。それは選びませんでした。

    高機能なものが持っているのは、主にブラウザ側でのキャッシュ管理です。このサイトはサーバー側で組み立ててHTMLを返す形が中心なので、その恩恵をあまり受けません。受けない機能のぶんだけ、覚えることと依存が増えます。

    いま使っているのは「問い合わせ文を投げて結果を受け取るだけ」のものです。必要になったら後から差し替えられるよう、問い合わせの実行部分は1か所に集めてあります。

    今回の学び

    まず、効くはずの設定が効かないときは、対象を読み直すということ。保存の指定はGET専用でした。書いた側からはエラーも出ないので、速くなっていないという結果からしか気づけません。

    次に、速くする変更は、壊れ方も一緒に変えること。事前に組み立てる形にした時点で、失敗する場所が「表示」から「公開作業」へ移っていました。速度の話をしているつもりでした。

    そして、相手はたまに失敗する、を前提に書くこと。共用サーバーは混みます。投げ直す、取れなければ薄いものを返す、どちらも「相手が常に応えてくれる」という前提を外した結果です。前提を外すと、コードは少し増えますが、止まらなくなりました。

  • そのページは何だと名乗っているか

    そのページは何だと名乗っているか

    用語集の分類ページを調べていて、妙な指定に気づきました。このページは「記事」だと名乗っていました。

    中身は用語が並んだ一覧です。公開日もなければ、書いた人もいません。それでも、SNSや検索エンジンに向けては「これは1本の読み物です」と伝えていました。

    この記事で扱うこと

    ページが自分を何だと名乗っているか、という話です。名乗りが実態とずれていた2ページと、そもそも何も名乗っていなかった4ページを直しました。

    既定値をそのまま連れてきていた

    SNSで共有されたときの見え方を決める指定に、type という項目があります。1本の記事なら article、そうでないページは website です。

    用語集の分類ページには、こう書いてありました。

    openGraph: {
      type: "article",
      title,
      description: cat.description,
    }

    記事ページの書き方をそのまま持ってきた形跡です。動かして確かめる対象ではないので、間違っていても画面には何も出ません。

    article が意味していること

    article は「公開日と書き手を持つ読み物」を指します。つまり、この指定を書いた時点で「いつ書かれたか」「誰が書いたか」があるものとして案内していることになります。

    実際にはどちらも無いので、受け取る側は探しに行って見つけられません。嘘をついているというより、名乗りだけが先に立っている状態です。

    同じ書き方がプライバシーポリシーにもありました。こちらも読み物ではありません。2ページとも website に直しました。

    何も名乗っていないページが4つあった

    ついでに構造化データを数えました。検索エンジンに「このページは何か」を直接伝える仕組みで、画面には出ません。

    ページ 直す前
    記事ページ BlogPosting + パンくず
    ブログ一覧 無し
    用語集 無し
    プロフィール 無し
    プライバシーポリシー 無し

    記事ページだけ揃っていて、それ以外は空でした。記事を書くときに考えたことが、そのとき触っていたファイルの中で完結していたわけです。

    記事用の部品を広げるか、別に作るか

    足すにあたって、記事用の部品を使い回すか迷いました。

    使い回そうとすると、日付の扱いで詰まります。記事には公開日があり、一覧にはありません。「日付が無いときは書かない」という枝を足すことになります。そのうち書き手についても同じ枝が必要になり、部品の中身が条件分岐で埋まっていきます。

    名乗る内容が違うものは、別の部品にする

    結局、一覧用に別の部品を作りました。記事は BlogPosting、一覧は CollectionPage、プロフィールは ProfilePage を名乗ります。

    同じ処理をしているように見えても、言っている内容が違うものは、混ぜると条件分岐になります。分けておけば、それぞれが1つのことだけを言います。

    パンくずは全ページに付けました。検索結果に出る階層表示に使われるもので、画面上の階層と一致していないと逆効果になります。実際のURLの形をそのまま写しただけです。

    ドメイン名が4か所に書いてあった

    作業中に、別の問題が見えました。サイトの絶対URL https://www.kisaku.site が、4つのファイルにそれぞれ書いてありました。

    robots.txt を作るところ、サイトマップを作るところ、RSSを作るところ、構造化データを作るところです。共通しているのは、どれも画面に出ない場所だということでした。

    ページの head に入れる相対パスは、設定してある基準URLが絶対URLへ直してくれます。直してくれないのは、この4か所のように文字列を自分で組み立てている場所だけです。

    1か所にまとめて、そこから読む形にしました。ドメインを変える日に1つ書き換え忘れても、画面を見ているかぎり気づけない種類の値だからです。

    今回の学び

    まず、ページは自分が何であるかを名乗っているということ。画面に出ない場所で、公開日や書き手を持つ読み物だと宣言していました。表示は正しく、指定だけが実態とずれていました。動かして確かめる対象ではないので、読みに行かないかぎり分かりません。

    次に、書き方をコピーすると、意味もコピーされること。記事ページの指定を持ってきたのは自然な流れですが、その中には「これは記事です」という宣言も含まれていました。形だけ借りたつもりで、中身まで一緒に来ていたわけです。

    そして、条件分岐が増えそうなときは、分ける合図だと考えること。1つの部品で両方をまかなおうとすると「日付が無いとき」「書き手が無いとき」の枝が並びます。それは、そもそも別のことを言おうとしている合図でした。

  • 見た目で区別が付いているものは、機械には区別が付いていない

    見た目で区別が付いているものは、機械には区別が付いていない

    ブログ一覧を見て、見出しの構造を確かめました。ページの題名にあたる見出しが、1つもありませんでした。

    画面ではちゃんと「ブログ」と大きく出ています。その下に記事の題名が並んでいます。誰が見ても、どちらが上でどちらが中身かは分かります。分からないのは、字の大きさが見えない側だけでした。

    この記事で扱うこと

    見た目で伝わっている区別が、機械には伝わっていなかった話です。何が起きていたか、どう直したか、そして「見えるから伝わっている」という思い込みについて書きます。

    全部が同じ格で並んでいた

    見出しには段階があります。h1 がそのページの題名、h2 がその中の章、というように、番号が入れ子の深さを表します。

    一覧ページを調べたときの中身は、こうなっていました。

    画面での見え方 実際の見出し
    ブログ(大きい文字) h2
    記事の題名 h2
    記事の題名 h2
    記事の題名 h2

    h1 はどこにもありません。そして「ブログ」と個々の記事が、同じ h2 で横並びになっていました。

    画面上は、文字の大きさが違うので迷いません。「ブログ」は太くて大きく、記事の題名はそれより小さい。人は大きさの差を、上下関係として読み取ります。

    大きさは、機械には渡っていない

    文字の大きさは見た目の指定であって、構造の指定ではありません。読み上げソフトが見出しをたどるとき、参照するのは番号のほうです。

    番号が同じなら同じ格です。したがって、たどっていくと「ブログ」も1本の記事も、並列の項目として現れます。このページが何のページなのかを名乗る見出しが、存在しないことになります。

    見出しは飾りではなく、目次として使われる

    見出しに番号を付ける意味は、画面を上から順に見られない人にとってはっきり出ます。

    読み上げソフトには、見出しだけを拾って移動する機能があります。本文を全部聞かずに、章の一覧から目的の場所へ飛べます。見出しの番号は、そのまま目次の階層になります。

    階層が平らだと、この目次は「同じ深さの項目が延々と並んだもの」になります。どれがページの題名で、どれが中身なのかが分かりません。全部を聞いて中身から判断することになります。

    検索エンジンから見ても事情は似ています。ページの主題を示す位置に何も無く、記事の題名だけが同じ格で並んでいる状態です。

    直すのは1か所ではなかった

    見出しは共通の部品で描いていました。ここが便利であると同時に、問題が広がった理由でもあります。

    部品の中で h2 を決め打ちしていたので、どのページで使っても必ず h2 になります。ページの題名として使いたい場合でも h2 でした。

    そこで、部品の側に「どの格で出すか」を渡せるようにしました。既定は今までどおり h2 のままにして、ページの題名として使うところだけ h1 を指定します。

    <SectionTitle as="h1">ブログ</SectionTitle>

    見た目は変えていません。字の大きさも余白もそのままです。変えたのは、機械が読む側の情報だけです。

    他のページも同じだった

    1か所で起きているなら、他でも起きています。全ページを取り寄せて h1 の数を数えました。

    ページ 直す前 直した後
    トップ 1つ 1つ
    ブログ一覧 0 1つ(ブログ)
    用語集 0 1つ(用語集)
    用語集の分類 0 1つ(分類名)
    プロフィール 0 1つ(プロフィール)
    プライバシーポリシー 0 1つ(ポリシー名)

    トップだけが正しく、残りは全滅でした。理由も同じで、トップは共通部品を使わずに直接書いていたからです。共通化した側だけが揃って間違っていました。

    数えれば分かる種類の不具合

    この確認は難しくありません。各ページのHTMLを取り寄せて、h1 がいくつあるかを数えるだけです。0でも2以上でも、何かおかしい合図になります。

    難しかったのは、確かめようと思うことのほうでした。画面上は最初から正しく見えていたので、疑う理由がありませんでした。

    今回の学び

    まず、見た目で付けた区別は、見た目の外には出ないということ。字を大きくすれば人には上位だと伝わりますが、それは番号にも構造にも変換されません。人に伝わっているかどうかと、機械に伝わっているかどうかは別に確かめる必要がありました。

    次に、共通部品は、正しさも間違いも同じだけ広げること。見出しの部品を1つにまとめたおかげで、直すときは1か所で済みました。同じ理由で、間違いも5ページに均等に配られていました。まとめた部分ほど、最初の設計を疑う価値があります。

    そして、「壊れていない」と「正しい」は違うということ。h1 が無くてもページは表示され、リンクも動き、誰も困った報告をしません。症状の出ない不具合は、症状を待っていても見つかりません。数えて確かめるまで、無いことに気づけませんでした。