タグ: ソース解説

  • ソース解説(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専用でした。書いた側からはエラーも出ないので、速くなっていないという結果からしか気づけません。

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

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

  • ソース解説(3) 旗を折るのではなく、読み込まない

    ソース解説(3) 旗を折るのではなく、読み込まない

    クッキー同意のバナーは、置くこと自体は難しくありません。難しいのは、「同意していない人には何も送っていない」と自分で言い切れる形にすることでした。

    今回はその部分のコードを読んでいきます。保存する形、版番号を持たせた理由、そして「読み込まない」という選び方です。

    この記事で扱うこと

    同意の記録をどう保存しているか、壊れた記録をどう扱うか、離れた部品へどうやって変化を伝えているか、そしてJavaScriptが動かない環境で何をしないことにしたかです。

    止めているのは1つだけ

    最初に決めたのは、何に対して同意を求めるかです。

    このサイトが読み込むものを数えると、外部へ通信するのはタグマネージャー1つでした。書体は自前で配信しているので提供元への通信は起きません。紹介リンクは、押して移動したときに初めて相手へ届きます。問い合わせの送信先は、送信ボタンを押したときだけ呼ばれます。

    項目を増やすほど、選んでもらえなくなる

    読了状態と明暗の設定も保存していますが、こちらはサイトの中で完結していて外へ出ません。止める理由が無いので、同意の対象に入れていません。

    並べるのは、実際に止まるものだけにしています。止まらないものを並べると、選ぶ手間だけが増えます。

    保存する形

    記録はこの形です。

    export type ConsentRecord = {
      analytics: boolean;
      decidedAt: string;
      version: number;
    };

    保存先はCookieではありません。サーバー側がこの値を使わないからです。Cookieはリクエストのたびに自動で送られますが、送り先に使い道がありません。ブラウザの中だけに置いています。

    decidedAt を持っているのは、あとから「いつ同意したか」を示せるようにするためです。

    版番号を持たせた理由

    version が、この設計でいちばん考えた部分です。

    const VERSION = 1;

    何に同意を求めるかが変わったら、この数字を上げます。版が違う記録は「まだ答えていない」として扱い、もう一度たずねます。

    古い同意を、新しい送信先に流用できない

    同意は「そのとき説明されたものに対して」もらっています。

    あとから送信先を1つ足したときに、以前の同意をそのまま使い回すと、その人が知らない相手へ黙って送り始めることになります。聞き直す手間より、そちらのほうが問題として大きい。

    版番号は、その判断を忘れても実行される形にしたものです。

    壊れている記録は、無かったことにする

    読み出す側は、自分で書いた値でも疑っています。

    const parsed: unknown = JSON.parse(raw);
    if (typeof parsed !== "object" || parsed === null) return null;
    
    const record = parsed as Partial<ConsentRecord>;
    if (typeof record.analytics !== "boolean") return null;
    if (record.version !== VERSION) return null;

    型を unknown で受けているのが要点です。保存されている文字列が、こちらの期待する形である保証はありません。古い形式が残っていることも、別のタブで書き換わっていることもあります。

    全体が try で囲んであり、保存が使えない環境では例外が出ます。そのときも null、つまり「同意していない」扱いになります。

    壊れたときに、どちらへ倒れるか

    プライベートモードなどで保存が読めないと、答えが分かりません。

    分からないときに送る側へ倒すか、送らない側へ倒すか。ここでは送らない側に倒しています。判断がつかない状態を「同意あり」として扱う理由がありません。

    書き込む側も同じ考え方です。保存に失敗しても、そのページのあいだは選んだとおりに動かします。記録が残らないので次に開いたときはまたたずねますが、「保存できないから同意も無かったことにする」よりは筋が通っています。

    離れた部品へ、どう伝えるか

    記録するのはバナー、それを見て読み込むかどうか決めるのはタグマネージャー側の部品です。2つは画面上の別の場所にあり、親子の関係もありません。

    保存の仕組みには「変わったよ」と教えてくれる機能がないので、自分で合図を投げています。

    window.dispatchEvent(
      new CustomEvent<ConsentRecord>(CONSENT_CHANGED, { detail: record }),
    );

    受け取る側は、こう待っています。

    useEffect(() => {
      const sync = () => setGranted(readConsent()?.analytics === true);
      sync();
      window.addEventListener(CONSENT_CHANGED, sync);
      return () => window.removeEventListener(CONSENT_CHANGED, sync);
    }, []);

    最初に1回読んで、そのあとは合図が来るたびに読み直します。最後の return は後片付けで、これを書かないと画面を移動するたびに待ち受けが増え続けます。

    旗を折るのではなく、読み込まない

    同意の実装には2通りあります。先に読み込んでおいて「送ってよいか」の旗だけ切り替えるやり方と、同意までそもそも読み込まないやり方です。

    ここでは後者にしました。

    if (!GTM_ID || !granted) return null;

    この1行で、同意が無いあいだは何も出力されません。

    前者だと、自分のコードでは確かめられない

    先に読み込む方式では、同意していない人のブラウザにもタグの本体が届いています。何を送る・送らないの判断が相手側の設定に移るので、こちらのコードを全部読んでも「同意前は何も送っていない」とは言い切れません。

    読み込まなければ、通信そのものが起きません。確かめたいことと、確かめられることが一致します。

    代償として、同意しなかった人の分は数字に出ません。それは正しく「同意しなかった人がいる」という事実なので、受け入れています。

    JavaScriptが動かない環境で、何をやめたか

    タグマネージャーには、JavaScriptが無効な環境向けに noscript の中へ枠を1つ置く決まりがあります。それを置くのをやめました。

    同意は、ブラウザに保存した記録を読んで判断しています。JavaScriptが動かない環境では、その記録を読むことも、同意をたずねることもできません。それでも枠だけは動きます。

    置いたままにすると、「同意を確かめられない人にだけ、確かめずに送る」ことになります。分からないときに送らない側へ倒す、という前提をここでも通しました。

    今回の学び

    まず、同意は対象とセットでしか意味を持たないということ。何に対する同意かが変われば、前の同意は使えません。版番号は、その判断を人の記憶に頼らず実行させるための仕掛けでした。

    次に、分からないときにどちらへ倒れるかを、先に決めておくこと。読めない・保存できない・確かめられない、という状態は必ず起きます。そのとき何が起きるかは、書いた時点で決まっています。

    そして、自分のコードで説明できる形を選ぶこと。旗を切り替える方式でも、おそらく実際には送られません。ただ、それは相手の設定を信じているだけで、こちらからは確かめられない。言い切れるかどうかで選びました。

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