タグ: TypeScript

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

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

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

    中身は用語が並んだ一覧です。公開日もなければ、書いた人もいません。それでも、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行足したつもりでした。実際には、そのページの共有画像が消えていました。

    しかも消えたのは、その日に触ったページだけではありません。調べたら、以前から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ページずつ取り寄せて初めて、書いてあるつもりのものが無いと分かりました。書いたコードではなく、出ている結果を見る必要がありました。

  • ソース解説(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のデータの形はこちらで決められません。手で書き写すとずれていくので、生成させています。ずれないことより、ずれようがない形にしてあることのほうが大事でした。

  • 「画面に入った」で数えたら、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か所で判定していて、どちらもそう呼んでいました。呼び名が一致していると、中身を照らし合わせる機会が来ません。ずれていたのは実装ではなく、言葉のほうが先に揃ってしまったことでした。

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

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

  • 通ったビルドが、何も直していなかった

    通ったビルドが、何も直していなかった

    ビルドが通った。テストも落ちていない。デプロイも成功した。

    それでも、直したかったものは1つも直っていませんでした。

    この記事で扱うこと

    変更が丸ごと消えた状態でコミットが成功してしまった話と、それがなぜ「成功」に見えたのか。そして、こういう失敗を見つけるための確認が「動くかどうか」ではなく「入っているかどうか」だという話です。

    やろうとしていたこと

    2つの修正を、1回の作業でまとめる予定でした。

    直したいもの 理由
    記事一覧の取得上限(20→100) 公開記事がちょうど20本。次の1本から漏れ始める
    構造化データの日時をGMTに 時差の情報が無い時刻を、機械に渡していた

    どちらもGraphQLのクエリを変える作業です。このプロジェクトはクエリから型を自動生成しているので、クエリを変えたら型を作り直す必要があります。作り直しにはCMSへの接続が要るため、手順はこうなりました。

    予定していた手順
    1. ソースを5ファイル書き換える
    2. npm run codegen   (型を作り直す)
    3. npm run build     (手元で通ることを確認)
    4. commit → push

    全部が「成功」した

    実行結果は、どこにも問題がありませんでした。

    codegenは通りました。ビルドも通りました。コミットもpushも成功し、Vercelのデプロイも緑になりました。本番サイトは変わらず表示されています。

    ところが本番の構造化データを見ると、日時が変わっていません。

    期待していたもの / 実際
    期待  "datePublished": "2026-08-22T04:20:00Z"
    実際  "datePublished": "2026-08-22T13:20:00"

    デプロイの遅れかと思い、キャッシュを避けて何度か取り直しました。変わりません。

    コミットの中身を見た

    ここでようやく、コミットに何が入っているかを確認しました。

    git show –stat
    frontend/src/gql/gql.ts     | 5 +++++
    frontend/src/gql/graphql.ts | 8 +++++++-
    2 files changed, 12 insertions(+), 1 deletion(-)

    2ファイル。しかも両方とも、自動生成されたファイルです。手で書き換えたはずの5ファイルが、1つも入っていません。

    作業ディレクトリを見ると、posts(first: 20) のまま戻っていました。書き換えたはずの内容が、どこにも残っていません。

    何が起きていたか

    git reflog に答えがありました。

    git reflog
    d370f37 HEAD@{0}: commit: fix: 記事一覧の上限を100に上げ...
    682faf3 HEAD@{1}: reset: moving to HEAD     ← これ
    682faf3 HEAD@{2}: pull: Fast-forward

    pullの直後、コミットの前に reset が挟まっています。git reset --hard は、コミットしていない変更を問答無用で消します。元に戻す手段はありません。

    つまり、書き換え → リセットで消滅 → その状態でcodegen → コミット、という順番で進んでいました。

    reset は「取り消し」ではなく「破棄」

    直前の操作を取り消したいときに git reset --hard を使いたくなりますが、これはコミット済みの履歴を戻す道具です。まだコミットしていない作業は、履歴に無いので戻せません。作業を残したまま状態を整えたいなら git stash のほうです。

    なぜ「成功」してしまったのか

    ここが今回いちばん考えさせられた点です。

    変更が消えているのに、なぜビルドが通ったのか。答えは、消えた結果として、全部が整合していたからです。

    クエリ側 生成された型 結果
    意図した状態 first: 100 first: 100 通る
    実際の状態 first: 20 first: 20 通る
    もし片方だけ残っていたら first: 100 first: 20 落ちる

    中途半端に消えていれば、ビルドが落ちて気づけました。きれいに全部消えたので、辻褄が合ってしまったわけです。

    ビルドが検証しているのは「矛盾が無いこと」であって、「意図が反映されていること」ではありません。何も変えていないコードは、当然のように矛盾しません。

    緑は「壊れていない」の意味

    CIやビルドの成功は、壊れていないことの証明です。直っていることの証明ではありません。この2つを同じものだと思っていると、今回のように「全部が緑なのに何も進んでいない」状態を見逃します。

    では、生成されたファイルの差分は何だったのか

    もうひとつ不思議だったのが、消えているのに gql.ts だけ差分が出ていたことです。

    調べると、生成されていたのは別のクエリの型でした。sitemap用に書いた、こういう文字列です。

    sitemapPosts.ts
    const POSTS_FOR_SITEMAP = /* GraphQL */ `
      query GetPostsForSitemap {
        posts(first: 100) { ... }
      }
    `;

    このファイルは、型生成に依存したくないからこそ素の文字列で書いていました。ところが先頭に付けた /* GraphQL */ という目印を、codegenが「これはクエリだ」と認識して走査対象に含めていました。

    結果として、型生成を避けるために作ったファイルが、型生成の対象になっていたわけです。意図とは正反対でした。

    失敗のおかげで見つかった

    この矛盾は、単体では表に出ません。使われない型がひっそり生成されるだけで、何も壊れないからです。別の失敗を調べていて、たまたま見つかりました。目印を外して、対象から除きました。

    次から何を見るか

    再発を防ぐために増やした確認は、1つだけです。

    コミットする前に
    git status --short

    並んでいるファイルの数を数えます。5ファイル触ったなら、5ファイル並んでいるはずです。2つしか無ければ、その時点でおかしい。

    やり直したときは、実際にこれで確認しました。7ファイル(手で書いた5つ + 生成された2つ)が並んでいることを見てからコミットしています。

    1回目 2回目
    コミットに入ったファイル 2 7
    ビルド 通った 通った
    本番の日時 変わらず Z が付いた

    1行目だけが、両者を区別できていました。

    今回の学び

    まず、ビルドの成功は「壊れていない」であって「直っている」ではないこと。今回は変更が丸ごと消えたことで整合が取れてしまい、すべての工程が緑になりました。矛盾を検査する仕組みは、何もしていない状態を歓迎します。

    次に、結果ではなく、入力を確認すること。「本番の表示が変わったか」で気づくのは最後です。コミットに何ファイル入ったかを見れば、pushする前に止められました。確認は下流より上流に置くほうが安いです。

    そして、git reset --hard は取り消しではないこと。コミットしていない作業に対しては、単なる破棄です。状態を整えたいだけなら stash を使う、という区別を持っておくと事故が減ります。

    最後に、避けたはずの仕組みに、目印ひとつで巻き込まれること。型生成から独立させるつもりのファイルが、コメント1つで走査対象になっていました。意図は、コードのどこかに具体的な形で表れていないと守られません。

  • 推測させるのをやめる

    推測させるのをやめる

    検索エンジンに、推測させるのをやめる。

    今回入れたのは3つです。sitemap.xml、robots.txt、そして構造化データ。バラバラの施策に見えますが、やっていることは同じです。これまで相手に推測させていたことを、こちらから明示的に伝える。それだけです。

    この記事で扱うこと

    3つがそれぞれ何を伝えているか、順位と発見速度は別物だという話、そして実装時に踏んだ「一覧用のクエリを流用してはいけなかった」という落とし穴。SEOの小手先ではなく、仕組みとして何が起きているかの話です。

    3つとも「伝える」ための仕組み

    先に全体像を置きます。

    伝えていること 無いとどうなるか
    sitemap.xml どんなページが存在するか リンクをたどって発見されるまで待つ
    robots.txt どこを見てよいか / 地図の場所 全部を手探りで巡回される
    構造化データ そのページが何なのか HTMLの見た目から推測される

    右の列に共通しているのは、「無くても動くが、相手の推測に任せることになる」という点です。推測は当たることもあれば外れることもあり、こちらからは制御できません。

    sitemap.xml — 順位ではなく、見つかるまでの時間

    まず誤解を解いておきます。sitemapを置いても、検索順位は上がりません。

    これは「見つけてもらうまでの時間」を短くするものです。検索エンジンは通常、リンクをたどってページを見つけます。外部からのリンクがまだ少ない新しいサイトでは、存在しているのに気づかれない期間が生まれます。sitemapは、そのたどる作業を省いて一覧を直接渡す仕組みです。

    Next.jsのApp Routerでは、app/sitemap.ts に既定の関数を書くだけで /sitemap.xml が生成されます。XMLを自分で組み立てる必要はありません。

    app/sitemap.ts(要点)
    export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
      const staticEntries = [
        { url: `${SITE_URL}/`,     changeFrequency: "weekly",  priority: 1   },
        { url: `${SITE_URL}/blog`, changeFrequency: "weekly",  priority: 0.8 },
        // ...
      ];
    
      const posts = await getPostsForSitemap();
      // ...
    }
    priority は「相対的な」重要度

    0から1の値ですが、これは検索エンジンに対する評価の申告ではありません。サイト内での相対的な優先度です。全部を1.0にすると差が無くなり、指定していないのと同じになります。

    20本の壁

    実装中に気づいた問題です。ここが今回いちばんの学びでした。

    記事一覧を取るための関数は、すでに用意してありました。getPosts() です。sitemapもこれを使えば済む——と思ったのですが、中身を見て手が止まりました。

    一覧用のクエリ
    query GetPosts {
      posts(first: 20) {   ← 20本で打ち切っている
        ...
      }
    }

    一覧ページに20本並べる、という表示上の都合でこうなっていました。これをsitemapに流用すると、21本目以降は「存在するのに検索エンジンへ知らせない記事」になります。

    気づかないまま進む種類の不具合

    この漏れは、エラーになりません。sitemapは正常に生成され、中身も正しく見えます。足りないことに気づく手がかりが、どこにもありません。公開記事がちょうど20本だったので、次の1本を公開した瞬間から静かに始まるところでした。

    教訓は単純です。「一覧に何本並べるか」と「検索エンジンに何本知らせるか」は別の話でした。同じ「記事一覧」という名前がついているせいで、同じものに見えていただけです。

    sitemap専用に、全件を取る関数を分けました。

    型生成への依存を、持ち込まない

    ここでもうひとつ判断が要りました。

    このプロジェクトのGraphQLクエリは、graphql-codegen が生成した型付きのドキュメントを経由しています。型が付くのは大きな利点ですが、制約がついてきます。クエリを1文字でも変えたら、型を作り直さなければなりません。そして作り直しにはCMSへ接続してスキーマを読む必要があります。

    得られるもの 引き換えに背負うもの
    型付きクエリ 取り違えを防げる CMSに繋がる環境でしか変更できない
    素の文字列 どこでも書ける 型は自分で書く

    sitemapが必要としているのは slug と modified の2つだけです。2つしか無いものを、取り違えようがありません。ここだけ素の文字列で問い合わせ、型は手で書くことにしました。

    仕組みの利点は、全部に適用しなくてよい

    型生成は良い仕組みですが、それに乗ることで「CMSに繋がらないと作業できない」という性質も一緒に受け取ります。その性質が邪魔になる場所だけ、外れてよいと判断しました。一貫性のために全部を揃えると、揃えた不便さも全部に広がります。

    robots.txt — 守っていないもの

    app/robots.ts を置くと /robots.txt が生成されます。中身はこれだけです。

    生成された robots.txt
    User-Agent: *
    Allow: /
    
    Host: https://www.kisaku.site
    Sitemap: https://www.kisaku.site/sitemap.xml

    ここで大事なのは、robots.txt はアクセス制限ではないことです。「クロールしてよい場所」を伝える取り決めであって、強制力はありません。守るかどうかは相手次第です。

    隠したいものを書いてはいけない

    「見られたくないから Disallow に書く」という使い方は逆効果です。robots.txt は誰でも読めるので、「ここに何かある」と書き残すことになります。本当に見せたくないものは、認証をかけるか、そもそも公開領域に置かないかのどちらかです。

    今回の実質的な役割は最後の1行、sitemapの場所を知らせることです。これがあると、Search Consoleに登録していない検索エンジンもsitemapを見つけられます。

    Host を書いているのは、このサイトが kisaku.site を www 側へ寄せているためです。どちらが正なのかを明示しています。

    構造化データ — 見た目から意味を切り離す

    3つめです。記事ページに、こういうものを埋め込みました。

    出力されるJSON-LD(抜粋)
    {
      "@type": "BlogPosting",
      "headline": "wwwのあるなしで、別のサイトになる",
      "datePublished": "2026-08-22T13:20:00",
      "dateModified":  "2026-08-22T14:21:46",
      "author":    { "@type": "Person", "name": "KiSAKU" },
      "publisher": { "@type": "Person", "name": "KiSAKU" }
    }

    画面には何も出ません。「これは記事で、公開日はこれで、書いたのはこの人」と直接伝えているだけです。

    これが無い場合、検索エンジンはHTMLの構造から推測します。「大きな文字だから見出しだろう」「日付っぽい文字列があるから公開日だろう」と。たいてい当たりますが、デザインを変えると解釈が変わることがあります。

    見た目を変えても、意味は動かない

    明示しておけば、日付の表示位置を変えても、見出しのサイズを変えても、伝わる内容は同じままです。装飾の変更が、意図せず情報を壊さない状態になります。

    パンくずも一緒に出す

    BreadcrumbList も併記しています。検索結果に出るパンくず表示のためです。

    階層
    ホーム › ブログ › (記事タイトル)

    ここで書く階層は、画面上の構造と一致させる必要があります。実際には無い階層を書くと、検索結果と実際のサイトが食い違い、逆効果になります。

    タグが途中で閉じないようにする

    細かいですが、実装上の注意点です。JSON-LDは <script> タグの中に書きます。もし記事タイトルに </script> という文字列が入っていたら、そこでタグが閉じてしまいます。

    対処
    JSON.stringify(jsonLd).replace(/</g, "\\u003c")

    < をユニコードのエスケープ表記に置き換えています。JSONとしての意味は変わらず、HTMLとしては無害になります。自分で書いた記事しか入らない場所でも、こういう箇所は塞いでおくほうが安いです。

    入れたら、実物で確かめる

    3つとも、公開後に実際のURLを叩いて確認しました。

    確認
    https://www.kisaku.site/robots.txt   → 4行が返る
    https://www.kisaku.site/sitemap.xml  → 固定ページ5 + 記事20 = 25件
    記事ページ → script[type="application/ld+json"] を読んで中身を検証

    特にsitemapは、件数を数えるまで安心できません。今回の20本の壁のように、正しく生成されているのに中身が足りない、という失敗があり得るからです。「エラーが出ていない」は「正しい」ではありません。

    今回の学び

    まず、3つとも「順位を上げる」ものではないこと。sitemapは発見までの時間を短くし、robots.txtは地図の場所を教え、構造化データは意味の推測を減らします。効果を正しく理解していないと、成果が出ないときに何を疑えばいいか分からなくなります。

    次に、似た名前のものを、同じものだと思わないこと。「記事一覧」という同じ名前でも、表示のための一覧と、検索エンジンへ渡す一覧は要件が違いました。流用は速いですが、要件が違うものを流用すると、後から静かに壊れます。

    そして、仕組みの利点は、全部に適用しなくてよいこと。型生成は良い仕組みですが、乗ると「CMSに繋がらないと変更できない」という性質も付いてきます。その性質が邪魔になる1か所だけ、外れる判断をしました。

    最後に、エラーが出ないことは、正しいことではないこと。sitemapの20本上限は、何のエラーも出さずに記事を落とし続けるところでした。出力されたものを数える、という確認をしなければ気づけませんでした。

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

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

    記事を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を使う。自分で書けば動きますが、ブラウザ側で最適化されている処理には敵いません。

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

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