カテゴリー: 技術系

  • ソース解説(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分ごとに作り直しても誰も早く受け取れません。速くする対象を選ぶのも、相手が誰かを見てからでした。

  • 細かく刻んでも直らなかった、経路を変えたら一度で通った

    細かく刻んでも直らなかった、経路を変えたら一度で通った

    記事のアイキャッチ画像を、CMSへ入れる作業をしていました。画像を文字列に変換して、少しずつ送り込む方法です。

    ところが、ときどき壊れます。送った文字列と、届いた文字列が違う。

    対処として分割をどんどん細かくしていきました。7000字で壊れたので2500字にし、それでも壊れたので1750字にしました。1750字なら通る。通ったのですが、1枚の画像を送るのに17回かかる計算になりました。

    この記事で扱うこと

    失敗する手順を細かく刻んで通した話と、そのあと経路そのものを変えたら一度で終わった話です。同じ作業が66回から1回になりました。

    細かくすれば通る、までは正しかった

    画像をそのまま送れないので、いったん文字の並びに変換します。変換すると元の1.3倍くらいの長さになり、1枚で2万8千字ほどになりました。

    これを一度に送ると壊れます。どこで壊れたのかを知るために、分割するたびに短い検査用の値を一緒に送って、届いた側で計算し直して突き合わせました。合わなければその回だけやり直せます。

    1回あたりの長さ 結果
    7000字 不一致
    2500字 不一致
    1750字 4回連続で一致

    1750字なら安定して通ります。これで「送れる方法」は確立しました。

    安定はしたが、量が減ったわけではない

    1枚17回。今回は4枚必要だったので、66回の往復になります。

    しかも毎回、検査用の値を計算して突き合わせます。1回でも合わなければ、その回をやり直します。作業として成立してはいますが、成立させるために手間が積み上がっている状態でした。

    細かくするのは、経路を疑っていない証拠だった

    ここで手が止まりました。1750字という数字に、根拠が無いことに気づいたからです。

    7000で駄目、2500で駄目、1750で通った。通ったという事実しかありません。なぜ1750なら大丈夫なのかは説明できていません。次に別の環境で同じことをしたら、また試して探すことになります。

    そして、そもそもの前提を疑っていませんでした。「この経路で送る」ことは決まったこととして扱い、その中でどう工夫するかだけを考えていました。

    ファイルをファイルのまま渡す

    やり直したのは、経路の側です。

    文字列に変換して少しずつ流し込むのではなく、画像ファイルをそのままCMSのアップロード欄へ渡しました。ブラウザがファイルを受け取る仕組みは元からあり、CMSにもファイルを受け取る画面が元からあります。使っていなかっただけです。

    文字列に変換して送る ファイルのまま渡す
    やり取りの回数 66回 1回
    送る量 元の約1.3倍 元のまま
    壊れたときの検知 自分で検査値を付ける 仕組みの側が持っている
    画像の劣化 途中で形式を変換していた 無し

    4枚まとめて、約1.9MBを一度で渡して終わりました。分割そのものが不要になったので、検査値の計算も、やり直しの判断も全部消えました。

    工夫が消えるのは、良い兆候

    1750字という調整も、検査値を突き合わせる仕組みも、それ自体は正しく動いていました。役目を終えて消えたのではなく、最初から必要の無い経路を選んでいたということです。

    手順の途中に自作の工夫が積み上がってきたら、それは経路を疑う合図でした。

    細かく刻む対処が要るときもある

    もちろん、分割が悪いわけではありません。

    経路がひとつしか無いなら、その中で安定する大きさを探すのは正しい対処です。今回もそれで一度は通しました。壊れたことに気づけるようにした部分は、経路を変えたあとも考え方として残ります。

    問題は、そこで止まったことです。「通す方法が見つかった」時点で満足してしまい、「そもそもこの経路でよかったのか」を考えませんでした。

    今回の学び

    まず、通った理由を説明できない手順は、まだ解けていないということ。1750字で通ったのは事実ですが、なぜ通るのかは分かっていません。説明できない数字が手順に残っているとき、それは運用でごまかしている合図でした。

    次に、工夫が積み上がるほど、前提を疑いにくくなること。分割の仕組みと検査の仕組みを自分で作った後だと、その経路を捨てる判断はしにくくなります。手間をかけたものほど、使い続ける理由になってしまいます。

    そして、「どうやってうまくやるか」の前に「この道でいいのか」を1回置くこと。今回は66回を1回にしました。差が出たのは工夫の質ではなく、経路の選び方でした。

  • ソース解説(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つの部品で両方をまかなおうとすると「日付が無いとき」「書き手が無いとき」の枝が並びます。それは、そもそも別のことを言おうとしている合図でした。

  • ソース解説(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が動かない環境では、その記録を読むことも、同意をたずねることもできません。それでも枠だけは動きます。

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

    今回の学び

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

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

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

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

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

    ブログ一覧を見て、見出しの構造を確かめました。ページの題名にあたる見出しが、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 が無くてもページは表示され、リンクも動き、誰も困った報告をしません。症状の出ない不具合は、症状を待っていても見つかりません。数えて確かめるまで、無いことに気づけませんでした。

  • 一覧と詳細だけのアプリで、フロントエンドの全体像を掴む(第0回)

    一覧と詳細だけのアプリで、フロントエンドの全体像を掴む(第0回)

    技術を1つずつ順番に勉強しても、「で、これいつ使うの」が解けないまま終わることがあります。

    この連載は逆から進みます。小さなアプリを1つ決めて、それを毎回作り直しながら必要な技術に出会っていくやり方です。

    そもそもフロントエンドエンジニアとは

    ひとことでいうと、ユーザーが目で見て指で触る部分を作る人です。

    Webサービスは大きく2つに分かれます。画面を作る「フロントエンド」と、データを保管・計算する「バックエンド」。レストランに例えるなら、フロントエンドはホールと内装、バックエンドは厨房です。料理そのものは厨房が作りますが、お客さんの体験を決めるのはホールでもあります。

    だから、技術力と同じくらい「見やすいか」「押しやすいか」が仕事の評価に直結します。この連載は、その両輪を扱います。

    最終回に手元に残るもの

    この連載を最後まで進めると、こういうものが自分のURLで動いています。

    • 一覧ページ(検索・絞り込みつき)
    • クリックすると開く詳細ページ
    • データはAPIから取得。読み込み中・データなし・通信失敗の表示も用意
    • スマホでもPCでも崩れない
    • GitHubに履歴が残り、pushすると自動で本番に反映される

    「チュートリアルを写経したもの」ではなく、仕様を自分で説明できるアプリです。面接でもレビューでも、これ1つあれば話が持ちます。

    なぜ一覧と詳細なのか

    地味に見えますが、実務のWebアプリはほとんどこの形の組み合わせです。商品一覧と商品ページ、記事一覧と記事ページ、管理画面の一覧と編集画面。

    そして、この小さなアプリを作る過程で、フロントエンドエンジニアに求められる技術がひと通り登場します。逆に言えば、技術を1つずつバラバラに学んでも「で、これいつ使うの?」が解けません。同じアプリを毎回作り直しながら進むのが、この連載のやり方です。

    積み上げの順番

    [土台]       HTML → CSS
    [動かす]     JavaScript → DOM → API通信
    [道具]       CLI → Git → Node/npm → TypeScript → React
    [守る]       テスト → ネットワーク → パフォーマンス
    [広げる]     バックエンド → クラウド → Docker
    [仕事にする] デザインツール・CMS
    

    上から順に進みます。下の段は上の段がないと理解できないので、飛ばさないでください。逆に、下の段を知らないことは今の時点で何の問題もありません。

    各パートのゴール

    Part 終わったとき何が動くか
    1. マークアップ 静的な一覧ページが、スマホでも崩れずに表示される
    2. JavaScriptの土台 データを取得して、一覧が自動で描画される
    3. 開発環境 Reactで作り直され、Gitに履歴が残っている
    4. 品質 テストが通り、表示速度を数値で説明できる
    5. 隣の領域 自分のAPIを持ち、インターネット上で公開されている
    6. ツール デザインデータから実装でき、公開まで一人で回せる

    進め方

    更新は週1回。各回はこの形で統一します。

    1. この回で解ける困りごと
    2. よくある書き方(Before)
    3. 直した書き方(After)
    4. なぜそうなるか
    5. 手を動かす課題
    6. 自己チェックリスト

    読むだけだと必ず抜け落ちます。5の課題だけは必ずやってください。 逆に、解説が難しく感じたら読み飛ばして課題に進んで構いません。手が先、理解は後から追いつきます。

    必要なもの

    パソコン、Google Chrome、Visual Studio Code。これだけです。有料ツールは使いません。

    次回はセマンティックHTMLです。divだらけで書いたページを、意味のあるタグに置き換えていきます。

    本連載の構成は、セイト先生(AIプログラミングスクールSiiD)の動画「フロントエンドエンジニアに必要なスキル」を参考に、独自の学習順序で再構成しています。

    今回の学び

    まず、作るものを先に決めると、学ぶ順番が自動で決まるということ。技術の一覧から選ぼうとすると優先順位が付きませんが、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ページずつ取り寄せて初めて、書いてあるつもりのものが無いと分かりました。書いたコードではなく、出ている結果を見る必要がありました。

  • ソース解説(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を振り直せばコードは簡単になりますが、外から張られたリンクが静かに壊れます。自分の都合で変えられるものと、変えられないものがあります。

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

  • 「送っていない」と言い切るために、同意まで読み込まないことにした

    「送っていない」と言い切るために、同意まで読み込まないことにした

    クッキーの同意を取る仕組みを入れました。作りながら一番考えたのは、見た目や文言ではなく、「同意していない状態」をどう表すかでした。

    この記事で扱うこと

    止める対象の数え方、旗を立てる方式と読み込まない方式の違い、JavaScriptが無い環境の扱い、そして同意の記録に版を持たせた理由です。法律の解釈ではなく、作りの話です。

    止める対象を、先に数えた

    最初にやったのは、ページを開いたときに外部へ通信するものを数えることでした。

    送り先 いつ通信するか 同意の対象か
    タグマネージャー(解析) ページを開いた瞬間 対象
    アフィリエイトのリンク先 押して移動したときだけ 対象外
    問い合わせの送信先 送信ボタンを押したときだけ 対象外
    書体 自前で配信しているので通信しない 対象外

    結果、止める必要があるのは1つだけでした。読了状態と画面の明暗もブラウザに保存していますが、こちらはサイトの中で完結していて外部へは出ません。

    項目を増やすほど、選べなくなる

    同意の画面に細かい分類を並べたくなりますが、実際には止まらないものを並べても、選ぶ手間が増えるだけです。

    読んでいる途中で急に出てくる画面なので、そこに置いた選択肢の数がそのまま負担になります。数えた結果が1つなら、1つだけ出すのが正しい。

    旗を立てるか、読み込まないか

    同意していない状態の表し方には、大きく2通りあります。

    ひとつは、タグを先に読み込んでおいて「送ってよいか」の旗だけを切り替えるやり方。もうひとつは、同意までそもそも読み込まないやり方です。

    旗を切り替える 読み込まない
    同意前のブラウザ タグ本体は届いている 何も届いていない
    送る・送らないの判断 相手側の設定にもある こちらのコードだけで決まる
    確かめ方 設定を含めて追う必要がある 通信が0件かを見れば済む
    同意しなかった人の数字 推定として残ることがある 残らない

    後者を選びました。

    確かめたいことと、確かめられることを一致させる

    確かめたいのは「同意前は何も送っていない」ことです。

    旗を切り替える方式では、タグ本体は相手のブラウザに届いています。何を送るかの判断が相手側の設定に移るので、こちらのコードを最後まで読んでも、送っていないと言い切れません。設定画面まで含めて追う必要が出ます。

    読み込まなければ、通信が起きる余地そのものがありません。開発者ツールで通信の一覧を見て0件なら、それが答えになります。実際に本番で測って、同意前は0件、「同意する」を押した瞬間に読み込まれることを確認しました。

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

    JavaScriptが無い環境をどうするか

    タグマネージャーには、JavaScriptが無効な環境向けにnoscriptで画像枠を置く決まりがあります。これを外しました。

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

    置いたままにすると、同意を確かめられない人にだけ、確かめずに送ることになります。分からないときに送らない側へ倒すのがこの仕組みの前提なので、ここでも送らないほうを選びました。

    バナーは操作を止めていない

    画面の中央に出して、選ぶまで先へ進めなくする作り方もあります。採らなかったのは、止める必要が無いからです。

    先に読み込みを止めてあるので、答えないまま読み進めても何も送られません。止めなければ計測が始められない作りなら、選ばせるまで通さない意味があります。先に止めてあるなら、たずねるほうは静かでよいはずです。

    ボタンは2つとも同じ大きさにしました。片方を小さくしたりリンクのように見せたりすると、選べるように見せかけて実際には選びにくい形になります。

    同意の記録に版を持たせた

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

    同意は「そのとき説明されたものに対して」もらっています。あとから送信先を足したときに古い同意を流用すると、その人が知らない相手へ黙って送り始めることになります。聞き直す手間より、そちらのほうが問題が大きいと考えました。

    今回の学び

    まず、選択肢の数は、数えてから決めること。分類を先に用意すると、実際には止まらないものまで並びます。外部へ通信するものを数えたら1つだったので、聞くことも1つになりました。

    次に、「していない」を証明できる形にしておくこと。旗を切り替える方式でも、実際には送られません。ただし、送っていないことをこちらのコードだけで示せません。同じ結果でも、確かめられるかどうかは別の性質です。

    そして、分からないときにどちらへ倒すかを、先に決めておくこと。JavaScriptが無い環境では同意を確かめられません。そのときに送るのか送らないのかは、個別に迷うと毎回違う結論になります。「分からないなら送らない」と決めておけば、判断が要らなくなります。