タグ: WordPress

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

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

    記事のアイキャッチ画像を、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回にしました。差が出たのは工夫の質ではなく、経路の選び方でした。

  • 分類は、探す人の手がかりではない

    分類は、探す人の手がかりではない

    用語集が116語になりました。分野ごとに8ページへ分けてあります。

    ある日、自分で「WAFってどこに書いたっけ」と探して気づきました。分野を当てないと、たどり着けません。

    この記事で扱うこと

    分類が誰のためのものかという話と、検索をブラウザ側に置いた判断、そして「打ち方の違いで見つからない」を潰すための文字のならし方です。

    分類は、書く側の都合でできている

    8つの分野は、書くときには役に立ちました。どこに何を書くかが決まり、抜けにも気づけます。

    しかし探す側から見ると、事情が違います。

    WAFを調べたい人がやること
    用語集を開く
      ↓
    8つの分野から、それらしいものを選ぶ  ← ここで当て推量
      ↓
    違ったら戻ってやり直す

    調べたい語が決まっている人にとって、分類は遠回りです。「WAFはセキュリティの話だ」と分かっている人は、そもそもWAFを調べません。

    整理した本人にしか使えない整理

    分類は、作った人には自明です。だから使いにくさに気づきません。自分で探して詰まって初めて、これは自分用の並べ方だったと分かりました。

    検索を、ページの先頭に置いた

    入力するとその場で絞り込まれ、押せば該当の語まで直接飛びます。分野の一覧は、検索していないときだけ出します。

    絞り込んだ結果の下に一覧が残っていると、どこまでが結果なのかが分からなくなるためです。

    打ち方の違いで、見つからないようにしない

    ここに手間をかけました。同じ言葉でも、打ち方は人によって違います。

    入力 探しているもの
    キャッシュ / きゃっしゅ / キャッシュ キャッシュ
    waf / WAF / WAF / わふ WAF
    resize / リサイズ ResizeObserver

    これを別物として扱うと、「あるのに見つからない」という結果になります。本人からすれば、無いのと同じです。

    比べる前に、全角と半角、大文字と小文字、カタカナとひらがなを一方に寄せています。読み(ひらがな)と、URLに使っているローマ字のIDも検索対象に含めました。日本語入力では変換の途中でも文字が届くので、確定前のひらがなでも当たるようにしてあります。

    なぜブラウザ側で絞り込むのか

    検索といえばサーバーに問い合わせる形が思い浮かびますが、今回は全部のデータを最初に渡してしまう形にしました。

    渡すもの
    語 + 読み + 1行の説明 + 置き場所  × 116語
    = 十数キロバイト(記事1本の本文より小さい)

    この量なら、最初に渡しても負担になりません。1文字打つたびに通信する必要がなくなり、待ち時間がゼロになります。

    規模が変われば、答えも変わる

    これは116語だから成立する判断です。数千語になれば、最初に渡す量が重くなり、サーバー側で絞り込む形が正しくなります。

    いまの規模で正しいことを選び、変わったら選び直すほうが、最初から大きく作るより速く進めます。

    残った引っかかり

    ひとつ、きれいでない点があります。用語の本文はCMS、検索用の索引はコードと、置き場所が分かれました。

    語を1つ足すには、両方を更新する必要があります。片方だけ直すと食い違います。

    いまは116語で、更新も自分1人なので回せます。ただしこれは「気を付ける」で運用している状態で、いつか間違えます。数が増えるか、更新する人が増えたら、索引をCMSから作る形へ変えるつもりです。

    今回の学び

    まず、分類は書く側の道具で、探す側の道具ではないこと。整理した本人には自明なので、使いにくさに気づけません。自分で探して詰まるまで分かりませんでした。

    次に、「あるのに見つからない」は、無いのと同じこと。打ち方の違いは利用者の間違いではありません。揃えるのは、こちら側の仕事です。

    そして、いまの規模で正しいものを選ぶこと。将来を見越して大きく作ると、そのぶん今日が遅くなります。変わったときに選び直せる形にしておけば十分です。

    最後に、気を付けて運用している場所は、書き留めておくこと。本文と索引が分かれている件は、いま把握しているから回るだけです。忘れた頃に食い違います。

  • 長さが合っているのに、中身が壊れていた

    長さが合っているのに、中身が壊れていた

    作った画像を、ブラウザ越しにCMSへ上げる必要がありました。一度に送れない大きさだったので、分割して送り、向こう側でつなぎ直しています。

    つなぎ直した長さは、元のデータと1文字も違いませんでした。それでも上がった画像は真っ黒でした。

    この記事で扱うこと

    長さが合っているのに中身が壊れていた原因と、それを見つけるまでに遠回りしたこと。そして分割して送るときに、検証をどこに置くべきかです。

    長さが合うことは、無事の証明にならない

    最初に確認したのは長さでした。送った文字数と受け取った文字数が一致していたので、無事に届いたと判断しました。

    これが間違いです。どこかで1文字増えて、どこかで1文字減れば、長さは変わりません。実際に起きていたのはこれでした。

    確かめやすいことを、確かめたいことの代わりにしていた

    確かめたかったのは「中身が同じか」です。確かめたのは「長さが同じか」でした。長さは数えるだけで済むので、つい代わりに使ってしまいました。

    長さが違えば壊れていると分かりますが、長さが同じでも壊れていないとは言えません。片方向にしか使えない検査を、両方向に使っていました。

    どこで壊れたのかが分からない

    次に困ったのが、壊れた場所の特定です。

    全体をひとつの塊として扱っていたので、「どこかが壊れている」以上のことが分かりません。やり直すとしたら全体をやり直すしかなく、しかも同じ場所でまた壊れるかどうかは運任せになります。

    実際、一度送り直しても同じように壊れました。何が起きているか分からないまま、同じ操作を繰り返していました。

    分割して送るなら、検証も分割する

    やり方を変えました。送る側で分割したときに、各かたまりごとに短い検査値を計算して一緒に渡します。受け取る側は、つなぐ前にその場で照合します。

    全体だけを検証 かたまりごとに検証
    壊れたことに気づける すべて送り終えたあと そのかたまりを受け取った時点
    壊れた場所 分からない そのかたまりだと確定する
    やり直す範囲 全体 そのかたまりだけ

    これに変えた途端、壊れている場所が一発で出ました。しかも受け取り側が拒否を返すので、壊れたまま先へ進むことがなくなります。

    やり直しの範囲は、検証の粒度で決まる

    検証を全体にしか置かないと、失敗も全体になります。細かく検証すれば、失敗も細かくなります。

    分割する目的は「一度に送れないから」でしたが、分割したことの利点は、失敗を小さくできることのほうにありました。

    検証は、転送の「あと」ではなく「一部」にする

    ここが今回の考え方の変化でした。

    最初は、送り終えてから確かめる、という順番で組んでいました。転送と検証が別の工程になっていて、間に「壊れたまま進む余地」があります。

    いまは、検査値が合わないかたまりは受け取らない形にしています。検証は転送の後工程ではなく、転送の一部です。こうすると「受け取れた」と「正しい」が同じ意味になります。

    形式として正しくても、中身が正しいとは限らない

    もうひとつ、別の層の話があります。

    検査値が合って、データが完全に届いた。そこまで確認しても、上がった画像が意図したものかは、まだ分かりません。実際、最初に壊れた画像は「画像ファイルとしては正しい形式」で、開くこともできました。中身が真っ黒だっただけです。

    そこで、送り先に登録する前に、実際に画素を読むようにしました。画面には出さず、いくつかの座標の色を取って、想定した配色になっているかを見ます。真っ黒なら、そこで止まります。

    出す前に、出すものを見る

    「形式として妥当か」と「意味として正しいか」は別の検査です。前者は自動で分かりますが、後者は中身を見に行かないと分かりません。

    面倒に見えますが、やっているのは数点の色を読むだけです。壊れたものを世に出してから気づくコストと比べれば、はるかに安く済みます。

    今回の学び

    まず、長さの一致は、内容の一致ではないこと。確かめやすい指標を、確かめたい事実の代わりに使うと、一致しているのに壊れている状態を見逃します。

    次に、失敗の大きさは、検証をどの粒度に置いたかで決まること。全体にしか検証を置かなければ、やり直しも全体になり、原因も特定できません。分けて送るなら、分けて確かめるべきでした。

    そして、検証は工程の後ろに置くと、壊れたまま進む余地が残ること。転送の一部にしてしまえば、受け取れたことがそのまま正しさの証明になります。順番を変えただけで、確認という作業そのものが要らなくなりました。

    最後に、形式の正しさと意味の正しさは、別々に確かめる必要があること。壊れた画像は画像として開けました。開けることと、意図したものであることは、まったく別の話でした。

  • 使っていないものは、書かない

    使っていないものは、書かない

    広告を入れて収益にできないか、という話から始まりました。

    調べていて手が止まったのは、広告そのものではありません。プライバシーポリシーのページが、このサイトに無かったことです。

    この記事で扱うこと

    広告を入れるかどうかに関係なく、すでに必要だったという話。テンプレートをそのまま貼らなかった理由。そして、置く場所と管理の仕方をどう決めたかです。

    広告の話から入ったが、広告の問題ではなかった

    広告配信サービスの規約を読むと、Cookieを使うことと、利用者が無効にできることを、プライバシーポリシーに明記するよう求められています。

    そこで自分のサイトを見て、ページ自体が無いことに気づきました。

    ただ、よく考えると広告の話が出る前から必要でした。

    すでにやっていたこと 本来必要だったこと
    アクセス解析でCookieを使う 何を集めているか、断る方法の説明
    問い合わせフォームを作った 受け取った情報の使い道の説明
    読了状態をブラウザに保存 保存していること自体の説明

    広告は4つめにすぎません。

    新しいことを検討して、古い抜けが見つかる

    今回は「広告を入れるには何が要るか」を調べたことで、すでに必要だったのに無かったものが出てきました。

    抜けは、抜けているという理由では見つかりません。誰も困っていないからです。別のことを始めようとしたときに、ついでに見つかることのほうが多いと思います。

    テンプレートをそのまま貼らなかった

    この手の文書には雛形がたくさんあります。貼れば5分で終わります。

    ただ、実際に読んでみると使っていないサービスの記述が入っていました。広告配信サービスを利用しています、という一文もそのひとつです。

    これを貼ると、使っていないのに使っていると書いてある状態になります。

    書いてあることは、確かめられる

    プライバシーポリシーは、読む人が確かめるための文書です。書いてあるとおりかどうかは、ページのソースを見れば分かります。

    実態と違うことが書いてあれば、それは説明ではなく、ただの飾りです。飾りを置くくらいなら、無いほうがまだ正直かもしれません。

    そこで、現時点では広告配信を行っていないと明記しました。始めたら、そのとき追記します。

    見えないことをしているなら、書く

    もうひとつ迷ったのが、読了状態の保存です。

    これはブラウザの中だけに記録していて、こちらのサーバーには届きません。個人を特定する情報でもありません。書かなくても問題にはならない種類のものです。

    それでも書きました。理由は、利用者から見えないからです。

    記事一覧に「読了済み」と出るのは、どこかに記録されているからです。読む人がそれに気づいたとき、どこに何が保存されているのか分かる場所があるほうがいい。法的に必要かどうかと、説明したほうがいいかどうかは、別の基準です。

    置き場所と、管理の仕方

    フッターの最下部に置いた

    ナビゲーションには入れませんでした。普段見たいものではないので、並べると他の項目の邪魔になります。

    一方で、探しに来た人が見つけられないのは困ります。フッターの最下部は、この種の文書が置かれる場所として広く定着しています。決まった場所にあることが、探す側にとっての手がかりになります。

    CMSから取る形にした

    文面をコードに直接書くこともできました。やめた理由は、更新のされ方です。

    コードに書いた場合
    サービスを1つ増やす
       ↓
    ファイルを編集して、ビルドして、デプロイする
       ↓
    面倒なので後回しになる
       ↓
    実態と食い違ったまま残る

    この文書は正しさが時間とともに失われる種類のものです。使っているサービスが変われば、その日から内容がずれます。

    用語集と同じく、管理画面から直せる形にしました。手順が軽いほど、実際に更新されます。

    今回の学び

    まず、新しいことを始める前に、足元の抜けが出てくること。広告の検討をしなければ、しばらく気づかないままでした。何かを増やそうとするときは、いま動いているものの前提も一緒に点検する機会だと思います。

    次に、テンプレートは、書いてある内容が自分に当てはまるか確かめてから使うこと。雛形は考える手間を省いてくれますが、確かめる手間まで省くと、事実と違う文書ができあがります。

    そして、法的に必要かどうかと、説明したほうがいいかどうかは別だということ。見えないところで何かをしているなら、書いておく理由はそれだけで十分です。

    最後に、更新されない場所に置くと、正しさは時間とともに失われること。書いた瞬間だけ正しい文書は、しばらく経つと嘘になります。直しやすい場所に置くことは、内容を正しく保つための設計の一部です。

  • 順番で決まる名前は、いつか壊れる

    順番で決まる名前は、いつか壊れる

    用語集の102語と、記事のあいだにリンクを張ろうとしました。

    ところが張る前の下調べで、そのリンクが最初から壊れる仕組みになっていることが分かりました。順番を変えただけで壊れます。

    この記事で扱うこと

    並び順から自動でIDを作ることの危うさ、公開前の記事へリンクしてしまっていた話、そして「直したつもり」で終わらせないための確認方法です。

    用語ページの中身を、直接指せるようにしてある

    用語集は分野ごとに1ページで、1ページに10〜18語ほど入っています。個々の語は見出しに id を振ってあり、こう書けば特定の語へ直接飛べます。

    語を直接指す
    /glossary/security/#waf

    記事から用語へリンクするには、この id が要ります。そこで中身を確認しました。

    半分以上が、意味のない名前だった

    IDは用語名から機械的に作っていました。英数字だけを残し、それ以外を捨てる方式です。

    用語 できたID
    WAF waf
    DNS dns
    ネームサーバー term
    名前解決 term-2
    サブドメイン term-3

    日本語の語は、英数字を残すと何も残りません。空になったものには、衝突しないように連番を振っていました。

    102語のうち55語がこの状態でした。

    なぜ、これが壊れるのか

    問題は読みにくさではありません。この連番が「何番目に出てきたか」で決まることです。

    用語を1語追加すると
    追加前                    追加後
    term    ネームサーバー     term    ネームサーバー
    term-2  名前解決           term-2  【ここに1語追加】  ← 割り込む
    term-3  サブドメイン       term-3  名前解決           ← ずれた
                              term-4  サブドメイン       ← ずれた

    用語を1語足すと、それ以降のIDが全部1つずつずれます。

    そして、ずれてもエラーは出ません。ページは正常に表示されます。リンクを踏んだ人が、ページの先頭に着地して「あれ?」と思うだけです。

    外から指される名前は、中の都合で変えてはいけない

    IDは、他の場所から指すための名前です。指す側は、指される側の並び順を知りません。

    用語集を充実させるほど、つまり良い方向に手を入れるほど、リンクが壊れていく。更新すると壊れる仕組みを先に作ってしまっていました。

    手で書いて、増えないようにした

    自動生成をやめ、102語すべてに固定のIDを手で割り当てました。

    対応表を1枚用意する
    "ネームサーバー": "nameserver",
    "名前解決":       "name-resolution",
    "サブドメイン":   "subdomain",

    手で書くのは面倒に見えますが、面倒なのは最初の1回だけです。追加する語は1行足すだけで、既存のIDは動きません。

    あわせて、対応表に無い語が来たら止まるようにしました。

    黙って埋めるより、止まったほうがいい

    未登録の語に自動で連番を振る作りだと、また同じ問題が静かに戻ってきます。止まれば、その場で1行足すだけで済みます。

    「気を付ける」で運用するより、間違えられない形にしておくほうが確実です。

    もうひとつ、公開前の記事へリンクしていた

    用語集には「関連記事」を載せています。ここも確認したところ、22件のうち8件が、まだ公開していない記事を指していました。

    用語集のページは公開済みです。つまり読者から見ると、リンクを踏むと404になります。

    原因は単純で、記事を書く前提でリンクを先に書いていたためです。

    そこで、公開済みの記事の一覧を持たせ、そこに載っていないリンクは出力しないようにしました。記事を公開したときに一覧へ1行足せば、リンクも一緒に出てきます。

    ついでに、リンクの文字をスラッグから記事タイトルに変えました。apex-vs-www と書かれても、押す前に中身が分かりません。

    直したあと、本当に直ったかを確かめる

    ここが今回いちばん大事な部分です。

    本番のページを書き換える必要がありましたが、「更新しました」という応答は、内容が正しいことを意味しません。途中で一部だけ置き換わっていても、成功として返ってきます。

    そこで、手元で作ったHTMLと、本番に入ったHTMLを数値に変換して突き合わせました。

    確認の流れ
    手元で組み立てたHTML   → 数値に変換 → A
    本番に入ったHTML       → 数値に変換 → B
    
    A と B が一致 → 1文字も違わない
    

    文章を1文字ずつ見比べるのは現実的ではありません。全体を1つの数値に潰してしまえば、一致するかどうかだけを見れば済みます。

    8ページすべてで一致しました。さらに、書き換えたあとで全リンクの飛び先が実在するかも機械的に確認しています。

    確認したこと 件数 壊れ
    記事 → 用語集のリンク 230 0
    用語集 → 記事のリンク 38 0
    下書きのまま残っているか 10件すべて —
    確認まで自動にしておく

    268本のリンクを手で踏んで確かめることはできません。目視で確認できない量になった時点で、確認する仕組みのほうを用意する必要があります。

    「たぶん大丈夫」と「1件も壊れていないことを確かめた」は、別のことです。

    今回の学び

    まず、外から指される名前は、中の都合で変わってはいけないこと。並び順・件数・登録した順番といった内部の事情でIDが決まると、中を整理するたびに外が壊れます。名前は約束なので、勝手に変えられない形にしておくべきです。

    次に、自動生成は、入力の性質を確かめてから使うこと。英数字を残す処理そのものは間違っていません。日本語の用語に対して使ったことが間違いでした。半分以上で空になると分かっていれば、最初から別の方法を選んでいたはずです。

    そして、足りないときは黙って埋めずに止めること。埋めてくれる親切な仕組みは、間違いも一緒に埋めます。止まれば気づけます。

    最後に、直したことと、直ったことは違うこと。更新が成功したという応答は、内容が正しい証明にはなりません。手元の正解と本番を突き合わせて初めて、直ったと言えます。この確認自体が数秒で終わるなら、やらない理由はありません。

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

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

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

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

    この記事で扱うこと

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

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

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

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

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

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

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

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

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

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

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

    1語1ページを検討した

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    今回の学び

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

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

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

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

  • 自分のブログに記事が投稿できない — WAFの誤検知と、渡し方を変える解決

    自分のブログに記事が投稿できない — WAFの誤検知と、渡し方を変える解決

    自分のブログに、自分で書いた記事が投稿できなくなりました。

    原因はサーバーのWAF(Webアプリケーションファイアウォール)でした。記事の本文に含まれるコードが、攻撃と誤検知されて遮断されていたのです。回避を4種類試して全部失敗し、最後に「内容を変えずに、渡し方を変える」ことで解決しました。

    この記事で扱うこと

    WAFによる誤検知、二分探索を使った原因の特定、実体参照によるすり抜けがなぜ効かないのか、そしてWordPressのインポート機能を使った解決。技術記事を書く人ほど踏みやすい問題です。

    403だけが返ってくる

    記事をWordPressのREST APIに送ると、403が返りました。

    返ってきたもの
    status: 403
    body: (空。WordPressのエラー形式ですらない)

    ここに最初の手がかりがあります。WordPressが権限で拒否する場合、rest_cannot_create のようなコードを含むJSONを返します。中身が空の403は、WordPressに届く前に止められていることを意味します。

    直前まで同じ手順で5本の記事を投稿できていました。コードも認証も変えていません。変えたのは記事の中身だけです。

    どこが引っかかっているのかを探す

    本文は約13,000文字あります。どこが原因かを目で探すのは現実的ではありません。

    そこで二分探索を使いました。本文の前半だけを送ってみて、通れば原因は後半にあり、弾かれれば前半にある。これを繰り返して範囲を半分ずつ狭めていきます。13,000文字なら、14回ほどで場所が特定できます。

    やったこと(ブラウザのコンソールで実行)
    const ok = async (text) => {
      const res = await fetch('/wp-json/wp/v2/posts', {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
        body: JSON.stringify({ title: 'probe', content: text, status: 'draft' })
      });
      return res.status === 201;
    };
    
    let lo = 0, hi = content.length;
    while (hi - lo > 16) {
      const mid = Math.floor((lo + hi) / 2);
      if (await ok(content.slice(0, mid))) lo = mid; else hi = mid;
    }
    console.log(content.slice(lo - 50, hi + 16));   // 境界の前後を表示

    結果はこうでした。

    const HEADING_PATTERN = 正規表現でHTMLの見出しタグを拾う行

    記事の中で紹介していた、見出しを抽出する正規表現です。WAFから見ると、HTMLタグを組み立てようとしている文字列=コード注入の試みに見えます。

    検証用のデータを残さない工夫

    上の関数は、投稿に成功した場合その投稿をすぐゴミ箱に入れています。二分探索は十数回の投稿を行うので、これをやらないと「probe」という記事が大量に残ります。調査のために本番環境を汚さないのは、調べる前に決めておくべきことです。

    すり抜けようとして、4回失敗した

    原因が分かったので、表示を変えずに検知だけ避ける方法を順に試しました。

    試したこと ねらい 結果
    バックスラッシュを数値文字参照に エスケープ記号を消す 失敗
    バッククォートも数値文字参照に コマンド置換に見える形を消す 失敗
    コード部分の記号をすべて数値文字参照に 記号の並びを根本から消す 失敗
    JSONではなくフォーム形式で送る 検査対象から外れることを期待 失敗

    3番目で分かったことが重要でした。WAFは実体参照を元に戻してから判定しています。

    これは考えてみれば当然です。攻撃者も同じ手を使うからです。<script> と書けば通ってしまうWAFには、防御の意味がありません。ブラウザが解釈する前の姿ではなく、解釈した後の姿で判定するのが正しい実装です。

    つまり、表示が同じである限り、エンコードを変えても結果は変わりません。目的が「表示は同じまま通す」ことである以上、この方向に出口はないと分かりました。

    ひとつだけ、通っていた経路があった

    行き詰まったところで、それまでの作業を振り返りました。

    記事の本文をブラウザに渡すために、13,000文字のJSONファイルをWordPressのメディアにアップロードしていました。そのアップロードは、一度も失敗していません。

    中身は同じです。同じ正規表現も、同じコードも入っています。それでも通っていました。

    送り方 中身 結果
    JSONの本文として送る 同じ 403
    フォームの項目として送る 同じ 403
    ファイルとして送る 同じ 成功

    このWAFは、フォームの入力値は検査するが、ファイルの中身は検査していないという動きをしていました。

    これは手抜きではなく、設計上の判断だと思われます。アップロードされるファイルは画像や動画やアーカイブで、中身は任意のバイト列です。すべてを攻撃パターンで検査すれば、正常なファイルが大量に誤検知されます。

    正規の道具が、すでに用意されていた

    ファイルなら通る。ではファイルとして記事を投入する方法はあるか。

    あります。WordPressのインポート機能です。サイト間で記事を引っ越すための仕組みで、記事をWXR(WordPress eXtended RSS)という形式のXMLファイルとして読み込みます。

    article6.xml(抜粋)
    <item>
      <title>読む体験をつくる</title>
      <dc:creator><![CDATA[sakurai_dev]]></dc:creator>
      <content:encoded><![CDATA[ ここに記事本文のHTML ]]></content:encoded>
      <wp:post_name><![CDATA[reading-experience]]></wp:post_name>
      <wp:status><![CDATA[publish]]></wp:status>
      <wp:post_type><![CDATA[post]]></wp:post_type>
      <category domain="category" nicename="tech"><![CDATA[技術系]]></category>
      <category domain="post_tag" nicename="react"><![CDATA[React]]></category>
    </item>

    本文は CDATA という区画に入れます。この中身はXMLとして解釈されないので、HTMLタグをそのまま書けます。

    「ツール → インポート → WordPress」からこのファイルをアップロードすると、記事・カテゴリ・タグがまとめて作られます。一度で通りました。

    抜け道ではない

    この方法は、WAFをだましているわけではありません。WordPressが元から用意している正規の移行手段を、そのまま使っただけです。防御は一切弱めていません。<script>alert(1)</script> は今も遮断されたままです。

    WAFを無効にしなかった理由

    途中、WAFを一時的に無効にして試したことがあります。実際、正規表現の行は通るようになりました。しかし記事全体はまだ弾かれ、<script> も遮断されたままでした。防御が複数の層で構成されていたためです。

    ここで「全部無効にする」という選択もありました。しかし、それはやめました。

    理由は単純です。この問題は今後も繰り返し起きます。技術記事を書く以上、本文にコードが入るのは避けられません。次に何が引っかかるかは予測できません。そのたびにWAFを無効にしていたら、いずれ「戻し忘れ」が起きます。

    作業のたびに防御を外す運用は、いつか必ず外したままになります。人間の注意力に頼る仕組みは、長く続けると必ず破れます。防御を触らずに済む経路が見つかったなら、そちらを選ぶべきです。

    今回の学び

    まず、空の403は「届いていない」という意味だということ。アプリケーションが返すエラーには、たいてい理由が書いてあります。理由の無いエラーは、アプリケーションより手前で止められています。どこを調べるべきかが、これで決まりました。

    次に、手作業で探さないこと。13,000文字から原因の1行を見つけるのに、二分探索なら14回で済みます。目で追えば見落としますし、時間もかかります。範囲を半分ずつ狭める方法は、原因が1か所に絞れる問題ならほぼ常に使えます。

    そして、同じ内容でも、渡し方が変われば結果が変わるということ。今回いちばんの発見はこれでした。中身をどう変形しても通らなかったものが、経路を変えた瞬間に通りました。行き詰まったら、変える対象を「中身」から「経路」に移す。この視点は他の場面でも使えます。

    最後に、防御を弱める解決を最後の手段にすること。無効にすれば即座に解決しますが、それは問題を先送りしているだけです。しかも「一時的に」で済んだためしがありません。遠回りに見えても、防御を維持したまま通る道を探す価値はありました。

  • 書いた記事をWordPressに届けるまで — CORS・nonce・CSSの壁

    書いた記事をWordPressに届けるまで — CORS・nonce・CSSの壁

    「作った」と「届いた」は違う

    ここまでの作業記録を記事にまとめ、HTMLファイルとして書き出しました。CSSも当て、ブラウザで開けばきちんと読める状態になっていました。私はそこで「記事の作成は完了した」と考えていました。

    しかし当然ながら、ファイルがローカルにあるだけでは誰も読めません。記事はブログに載って初めて記事です。成果物の形が合っていても、あるべき場所に届いていなければ完了ではない——今回いちばん効いた教訓はこれでした。

    そこで「HTMLファイル4本を、WordPressの投稿として登録する」という作業が発生します。手作業でコピー&ペーストすれば済む話に見えますが、1本あたり1万文字を超えるHTMLです。しかも今回の環境では、その素朴な方法が使えませんでした。この記事は、その受け渡し経路を作るまでの記録です。

    この記事で扱うこと

    ブラウザの同一オリジンポリシー(CORS)、WordPressのREST APIとnonce、アップロード可能なファイル形式の判定、React(Next.js)で外部HTMLを表示するときのCSS設計、そして公開後の検証方法。どれも「WordPressとNext.jsをつなぐ」構成では避けて通れない話です。

    通せる経路を探す

    状況を整理します。記事のHTMLは手元の作業環境にあり、WordPressの管理画面はブラウザで開いています。この2つの間には直通の道がありません。

    考えられる経路を順に試しました。

    案 やり方 結果
    1 GitHubのファイルURLをブラウザから直接読み込む 失敗。リポジトリが非公開なうえ、別ドメインなのでCORSで弾かれる
    2 作業環境から直接WordPressのAPIを叩く 失敗。作業環境からの外向き通信が制限されている
    3 記事本文をそのままスクリプトに書き込んで実行する 理屈上は可能。ただし4本で約5万文字。引用符やバックスラッシュのエスケープを1文字でも間違えると壊れる
    4 メディアライブラリにファイルをアップロードし、そのURLから読む 最初は失敗。原因の切り分けに時間を使った(後述)

    最終的に採用したのは、この4案のどれでもない5番目の方法でした。ただしそこにたどり着くには、まず1番の失敗が何を意味しているかを理解する必要があります。

    なぜブラウザは他所のデータを読ませないのか

    案1で出たエラーは Failed to fetch でした。ファイルが無いのでも、通信ができないのでもありません。ブラウザが意図的に読み取りを止めています。

    ブラウザには同一オリジンポリシーという原則があります。オリジンとは「プロトコル+ドメイン+ポート」の組のことで、https://cms.kisaku.site と https://github.com は別のオリジンです。原則として、あるオリジンのページで動くJavaScriptは、別のオリジンのデータを読めません。

    なぜそんな制限があるのか。もし制限が無ければ、こういうことが起きます。

    悪意のあるサイトで動くJavaScript(もし制限が無ければ成立してしまう)
    // 訪問者がネットバンクにログイン済みなら、
    // Cookieが一緒に送られて「本人のリクエスト」として通ってしまう
    const res = await fetch('https://bank.example.com/api/balance', {
      credentials: 'include'
    });
    const data = await res.json();   // 残高が読めてしまう
    send_to_attacker(data);

    つまり同一オリジンポリシーは、「ログイン状態を勝手に借用して他所のデータを盗む」ことを防ぐための仕組みです。CORS(Cross-Origin Resource Sharing)は、その原則に対して「このオリジンからなら読ませてよい」とデータの提供側が明示的に許可を出すための仕組みです。許可を出すのは受け取る側ではなく渡す側だという点が重要で、だから今回のように相手がGitHubであれば、こちらの都合で許可を足すことはできません。

    よくある誤解

    「CORSエラーが出た=サーバーが落ちている/URLが間違っている」と考えてしまいがちですが、違います。リクエスト自体はサーバーに届いていて、レスポンスも返ってきています。ブラウザがそれをJavaScriptに渡さないだけです。curl で同じURLを叩くと普通に取得できるのは、curlにはこの制限が無いからです。

    ファイル選択欄を通り道にする

    ここで発想を変えます。ブラウザが「別のオリジンから取ってくる」のを止めるなら、取ってこなければいい。利用者が自分の意思でファイルを選んで渡す経路なら、CORSは関係ありません。

    ファイル選択欄(<input type="file">)がまさにそれです。ここには、セキュリティ上の重要な非対称性があります。

    操作 可否 理由
    JavaScriptからファイルパスを設定する できない できてしまうと、サイト側が勝手に利用者のファイルを盗み出せる
    選ばれたファイルの中身をJavaScriptから読む できる 利用者が明示的に選んだファイルなので、同意があるとみなせる

    この「選ばれた後なら読める」を使います。ポイントは、ファイルをアップロードはしないことです。選択欄に入れるところまでで止め、中身だけをJavaScriptで読み出します。

    WordPress管理画面のコンソールで実行
    // 「メディアを追加」ページのファイル選択欄
    const input = document.getElementById('async-upload');
    const file  = input.files[0];
    
    // アップロードはしない。中身だけ読む。
    // File.text() はテキストとして読み出すメソッド(Promiseを返す)
    const text = await file.text();
    const posts = JSON.parse(text);
    
    console.log(posts.length);          // 4
    console.log(posts[0].content.length); // 11344

    記事4本をJSONの配列にまとめた1つのファイルを用意し、それを選択欄に入れて読み込みました。これで、5万文字のHTMLがブラウザのメモリ上に乗ります。CORSも通信も介在しません。

    この方法の良いところ

    メディアライブラリに中継用ファイルが残りません。「サーバーに置いてから読む」方式だと、用が済んだあとのファイルが公開状態で残り続けます。今回のように一度きりの受け渡しでは、置かずに済ませるほうが後始末が要りません。

    「権限がありません」の正体

    この方法にたどり着く前に、案4(メディアにアップロードしてから読む)を試していました。そこで出たのがこのエラーです。

    このファイルタイプをアップロードする権限がありません。

    管理者としてログインしているのに「権限がありません」と言われる。素直に読むと「WordPressが .txt を受け付けない設定になっている」と解釈したくなります。しかし調べると、WordPressは標準で text/plain を許可しています。話が合いません。

    そこで、同じことをREST API経由で試しました。小さなファイルを作って投げてみます。

    許可されている形式を実際に確かめる
    const form = new FormData();
    form.append('file', new Blob(['hello'], {type: 'text/plain'}), 'test.txt');
    
    const res = await fetch('/wp-json/wp/v2/media', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {'X-WP-Nonce': nonce},
      body: form
    });
    console.log(res.status);   // 201 → 作成成功

    201が返りました。.txt は問題なくアップロードできる。つまりエラーの原因はファイル形式ではありませんでした。管理画面のフォームを操作する過程で、フォームに埋め込まれていたnonceが本来の組み合わせから外れてしまい、WordPressが「この操作は正当な経路から来ていない」と判断していたのです。

    nonceとは何か

    nonceは “number used once” の略で、その操作が正規の画面から、本人の意思で行われたことを示す使い捨ての合言葉です。Cookieだけで認証していると、こういう攻撃が成立してしまいます。

    CSRF(クロスサイトリクエストフォージェリ)の例
    <!-- 攻撃者のページ。訪問者がWordPressにログイン済みだと…… -->
    <form action="https://example.com/wp/wp-admin/user-new.php" method="post">
      <input name="user_login" value="attacker">
      <input name="role" value="administrator">
    </form>
    <script>document.forms[0].submit();</script>

    Cookieはドメインに対して自動で送られるので、リクエストは「ログイン中の管理者からの正当な要求」に見えてしまいます。nonceがあると、攻撃者はその値を知り得ないため、この形の攻撃は成立しません。

    ややこしいのは、WordPressのnonceは用途ごとに別物だという点です。管理画面のフォームに埋まっているnonceと、REST APIが要求するnonceは別で、片方をもう片方に使うと弾かれます。

    用途 取得方法 渡し方
    管理画面のフォーム送信 フォーム内の hidden 項目(_wpnonce) フォームの一部として送信
    REST API wpApiSettings.nonce、または admin-ajax.php?action=rest-nonce X-WP-Nonce ヘッダ

    最初、私はページのHTMLから正規表現で10桁の16進数を拾ってnonceにしていました。当然これは別用途のnonceを掴むことがあり、rest_cookie_invalid_nonce という403が返ります。確実な取り方は次のとおりです。

    REST API用のnonceを確実に取る
    // WordPressが専用に用意しているエンドポイント。
    // 管理画面にログインしていれば、有効なnonceを文字列で返す。
    const nonce = (await fetch('/wp/wp-admin/admin-ajax.php?action=rest-nonce', {
      credentials: 'same-origin'
    }).then(r => r.text())).trim();
    切り分けの考え方

    エラーメッセージは「何が起きたか」を教えてくれますが、「なぜ起きたか」は教えてくれません。「権限がありません」を額面どおり受け取っていたら、許可形式を増やす設定変更に進んでいたはずです。実際の原因は認証の受け渡しでした。別の経路で同じことを試して結果が変わるなら、原因は自分が疑っていた場所には無い——これは今回いちばん役に立った切り分け方でした。

    REST APIで下書きを作る

    本文がブラウザ上に乗り、有効なnonceも手に入りました。あとは投稿を作るだけです。

    投稿を下書きとして作成する
    const post = posts[0];
    
    const res = await fetch('/wp-json/wp/v2/posts', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-WP-Nonce': nonce
      },
      body: JSON.stringify({
        title:      post.title,
        content:    post.content,
        status:     'draft',   // いきなり公開しない
        categories: [11]       // 「技術系」カテゴリのID
      })
    });
    
    const created = await res.json();
    console.log(res.status, created.id);   // 201 66

    status を draft にしているのは意図的です。いきなり publish にすると、表示が崩れていた場合に崩れた状態が世に出ます。下書きなら、確認してから公開できます。取り返しのつく順番で進めるというだけの話ですが、効果は大きいです。

    保存されたものを疑う

    201が返ったから成功、とはしませんでした。WordPressには wp_kses_post() という仕組みがあり、投稿本文から危険なタグや属性を削り落とすことがあります。今回の記事は class 属性に強く依存しているので、これが削られると装飾が全部消えます。

    削られるかどうかは、投稿するユーザーが unfiltered_html という権限を持つかで決まります。単一サイトの管理者は持っていますが、それは「持っているはず」であって「確認した」ではありません。そこで、保存されたものを読み戻して元と突き合わせました。

    保存後の本文が元と一致するか確かめる
    // context=edit を付けると、加工前の生データ(content.raw)が返る
    const res = await fetch('/wp-json/wp/v2/posts/66?context=edit', {
      credentials: 'same-origin',
      headers: {'X-WP-Nonce': nonce}
    });
    const saved = await res.json();
    
    console.log(saved.content.raw === post.content);       // true
    console.log((saved.content.raw.match(/<pre/g) || []).length);  // 12
    console.log(saved.status, saved.categories);           // draft [11]

    context=edit を付けるのがポイントです。これを付けないと表示用に整形された content.rendered しか返らず、元と比べても差が出てしまいます。生データ同士で比較して初めて「1文字も失われていない」と言えます。

    スラッグを先に整える

    公開する前に、URLの末尾になるスラッグを英語に変えました。日本語タイトルのまま公開すると、スラッグも日本語になり、URLがパーセントエンコードされて非常に長くなります。

    スラッグを後から変えるとリンクが切れる
    // 日本語スラッグの実際の姿
    /blog/%E3%81%AA%E3%81%9C%E3%81%93%E3%81%AE%E6%A7%8B%E6%88%90...
    
    // 変更後
    /blog/headless-wordpress-nextjs-architecture

    これを公開前にやるのには理由があります。公開後にスラッグを変えると、それまでのURLが404になります。SNSで共有されていれば、そのリンクは死にます。URLは公開した瞬間に「他人が持つ資産」になるので、後から変えられる前提で設計してはいけません。

    CSSが当たらない

    記事を1本公開して、フロント(Next.js側)で見てみると、文章は出るものの装飾が一切効いていませんでした。コードブロックは白背景のただの文字、補足ボックスも普通の段落です。

    原因は2つ重なっていました。

    1. className は流し込んだHTMLの中には届かない

    WordPressの本文は、React側では次のように表示しています。

    frontend/src/app/blog/[slug]/page.tsx
    <div
      className="wp-content mt-8 rounded-2xl bg-white p-6"
      dangerouslySetInnerHTML={{ __html: post.content }}
    />

    dangerouslySetInnerHTML は「このHTMLを解釈せずそのまま入れる」という指定です。中身はReactのコンポーネントではないので、Reactが管理する仕組み(className を渡す、コンポーネントを差し込む)は一切届きません。外側の div にどれだけクラスを足しても、内側の h2 や pre には影響しません。

    したがって、CSS側で「この箱の中の要素」として受けるしかありません。

    2. Tailwindが標準のスタイルを消している

    Tailwind CSSにはpreflightという機能があり、読み込むと h2 の文字サイズや p の余白といったブラウザ標準のスタイルを全部リセットします。ブラウザ差をなくすための仕組みですが、外部から流し込んだHTMLには致命的で、見出しと本文が同じ見た目になります。

    この2つをまとめて解決する形が、こうなります。

    frontend/src/app/globals.css
    .wp-content {
      /* 色は変数にまとめる。調整を1か所で済ませるため */
      --code-bg: #1c2128;
      --line:    #e3e6ea;
    
      line-height: 2;    /* 日本語は行間を広く取ると格段に読みやすい */
      font-size: 17px;
    }
    
    /* 装飾ではなく「上の余白」で階層を出す。
       線や色を足すより、空白のほうが読み手には強く効く */
    .wp-content h2 {
      font-size: 1.5rem;
      font-weight: 700;
      margin: 4.5rem 0 1.5rem;
      scroll-margin-top: 1.25rem;   /* 目次から飛んだとき上端に貼り付かない */
    }
    
    /* コードは濃色にして「ここは読み物ではない」と視覚的に伝える */
    .wp-content pre {
      background: var(--code-bg);
      color: #dfe3e8;
      border-radius: 8px;
      padding: 1.1rem 1.25rem;
      overflow-x: auto;   /* 折り返さない。折り返すとコードの構造が壊れる */
    }
    
    /* スマホで表がはみ出すのを防ぐ。
       表そのものではなくラッパーをスクロールさせるのがポイント */
    .wp-content .table-wrap { overflow-x: auto; }
    なぜ prose(Tailwind Typography)を使わないのか

    Tailwindには @tailwindcss/typography という、まさにこの用途のプラグインがあります。導入すれば見出しや段落は一発で整います。今回それを使わなかったのは、この記事群が .note や .code-block といった独自のclassを使っており、そこは結局自分で書く必要があるためです。半分プラグイン・半分自前だと、どちらに何が書いてあるか分からなくなります。既製品を入れるかどうかは「どこまで賄えるか」で決めるのが良さそうです。

    公開後の確認項目

    公開したあと、次の項目を確認しました。「見た目が合っていそう」で終わらせないための一覧です。

    確認すること 方法 なぜ見るのか
    一覧ページに出ているか ブログ一覧のリンクにスラッグが含まれるか 個別ページが正しくても、一覧に出なければ誰にも見つからない
    個別ページが200を返すか fetch(url) のステータス 404や500でないこと。スラッグの設定ミスはここに出る
    本文が入っているか HTML内に wp-content の箱があるか 取得に失敗しても、ページ自体は200で返ることがある
    HTMLがエスケープされていないか &lt;h2&gt; のような文字列が本文に出ていないか どこかでエスケープが挟まると、記事全体がタグの羅列になる
    CSSが実際に効いているか getComputedStyle() で計算後の値を見る 目視だと「効いているように見える」で通してしまう
    CSSが本当に適用されているかを値で確かめる
    const box = document.querySelector('.wp-content');
    const pre = box.querySelector('pre');
    
    console.log(getComputedStyle(pre).backgroundColor);
    // "rgb(28, 33, 40)" ← 指定した --code-bg と一致
    
    console.log(getComputedStyle(box).lineHeight);
    // "34px" ← 17px × 2.0。行間の指定も効いている

    getComputedStyle() は、CSSの継承や優先順位をすべて解決したあとの最終的な値を返します。「スタイルシートには書いたが、より強い指定に負けていた」というよくある失敗が、これを見れば一発で分かります。目で見て確認すると、うっすら効いている状態を「効いている」と判断してしまいがちです。

    おまけ:rebaseが止まるとき

    CSSをコミットしようとしたとき、以前と同じところで詰まりました。

    よく出るエラー
    error: cannot pull with rebase: You have unstaged changes.

    rebaseは「自分のコミットを、取り込んだ最新の状態の上に載せ直す」操作です。作業中の未コミットの変更が残っていると、載せ直す途中でそれが失われる危険があるため、Gitは先に止まります。

    毎回 git stash と git stash pop を手で打っていたのですが、これを自動でやるオプションがあります。

    退避と復帰を自動でやる
    git pull --rebase --autostash
    
    # 内部でやっていること:
    #   1. 未コミットの変更を一時退避(stash)
    #   2. rebase を実行
    #   3. 退避したものを戻す(stash pop)

    今回のように「関係ない別ファイルの変更が手元に残っている」場面では、これで止まらずに済みます。

    今回の学び

    技術的な内容より先に、いちばん大きかったのは冒頭の話です。ファイルを作ったことと、それが読める場所にあることは別。今回は「記事を書く」という依頼に対して、HTMLファイルを渡した時点で完了だと考えてしまっていました。依頼した側から見れば、ブログに載っていなければ何も起きていないのと同じです。

    技術面では、次の3つが残りました。

    ひとつ目は、制約は迂回するのではなく、その意味を理解してから別の道を探すということ。CORSに阻まれたとき、抜け道を探すのではなく「なぜこの制限があるのか」を理解したことで、ファイル選択欄という筋の通った経路にたどり着けました。制限の理由が分かれば、その理由に反しない方法も見えてきます。

    ふたつ目は、エラーメッセージは症状であって原因ではないということ。「権限がありません」を額面どおり受け取っていたら、許可設定をいじる方向に進んでいました。別の経路で同じ操作を試し、結果が変わったことで、原因が別の場所にあると分かりました。

    みっつ目は、成功したという返事を信じないということ。201が返っても、中身が削られていないかは別問題です。保存されたものを読み戻して元と比較する。CSSは目で見ずに計算値で確認する。ひと手間ですが、この確認があるから「できました」と言い切れます。

  • なぜこの構成にしたのか — ヘッドレスWordPress + Next.js の技術選定

    なぜこの構成にしたのか — ヘッドレスWordPress + Next.js の技術選定

    ポートフォリオサイトを、素のHTMLで書かれた静的サイトから「ヘッドレスWordPress + Next.js」という構成に作り直しました。前回までの記事では、実際にやった作業と、そこでつまずいた内容を書いてきました。

    今回は少し引いた視点で、そもそもなぜこの構成にしたのかを書きます。技術選定の話です。

    結論から言うと、この構成は万人向けではありません。個人のポートフォリオという規模に対しては、明らかに過剰な部分があります。それでも選んだ理由と、その代わりに何を失ったのかまで含めて残しておきます。選定の記事は、良い点だけ並べても参考にならないと思うからです。

    まず全体像

    完成した構成はこうなっています。

    訪問者のブラウザ

    ↓  https://www.kisaku.site
    Vercel
    Next.js(App Router / TypeScript)
    ページの見た目を作って返す

    ↓  GraphQL(サーバー間の通信)
    お名前.com 共用サーバー
    https://cms.kisaku.site
    WordPress(管理画面 + データベース)
    WPGraphQL / ACF

    記事を書くのはWordPressの管理画面。表示するのはNext.js。両者はGraphQLというやり取りの決まりでつながっています。

    用語

    ヘッドレスとは「頭がない」という意味で、この場合の”頭”は表示部分を指します。WordPressから表示機能を切り離し、データを保管して受け渡すことに専念させる構成のことです。

    なぜヘッドレスにしたのか

    元のサイトは素のHTML・CSS・JavaScriptで書かれた静的サイトでした。表示は速く、構成もシンプル。悪くありません。

    ただ、実績を1件追加するのにHTMLを直接編集する必要がありました。文章の言い回しを直したいだけなのに、エディタでタグを探して、間違えないように書き換えて、アップロードし直す。これを何度か繰り返すうちに、更新のしやすさが欲しくなりました。

    ここで普通に考えると、選択肢は2つあります。

    案 内容 更新のしやすさ 表示の自由度
    A 普通にWordPressテーマを作る ◎ △(PHPのテーマの作法に従う)
    B ヘッドレス構成にする ◎ ◎(フロントは完全に自由)

    案Aで十分だったと思います。実際、更新のしやすさだけが目的ならそれが正解です。

    それでも案Bを選んだ理由は2つあります。

    ひとつは表示側を自由に作りたかったこと。WordPressのテーマを作る場合、PHPのテンプレート階層という作法に沿うことになります。慣れれば速いのですが、今回は「もとのサイトの見た目を保ちつつ、新しい書き方で作り直す」のが目的だったので、表示側は白紙から書きたかった。

    もうひとつが正直なところ大きいのですが、学習目的です。普段の実務ではヘッドレスCMSやGraphQL、Next.jsのApp Routerに触れる機会がありません。自分のポートフォリオという、壊れても誰も困らない題材で試してみたかった、というのが本音です。

    注意

    技術選定において「学習目的」は立派な理由になりますが、それが理由であることを自覚しておくのは大事だと思います。仕事で同じ判断をするなら、案Aのほうが妥当な場面は多いはずです。

    なぜWordPressを残したのか

    ヘッドレス構成にするなら、データを持つ側はWordPressである必要はありません。microCMSやContentfulのような最初からヘッドレス前提で作られたCMSもあります。

    それでもWordPressを選んだ理由は3つです。

    1. 管理画面の完成度

    記事を書く画面、画像をアップロードする画面、カテゴリを整理する画面——WordPressのそれらは20年かけて磨かれています。自分で書くつもりなら、この使い勝手は無視できません。

    2. ACFによる構造化

    今回のサイトには「実績」と「スキル」という、普通の記事とは形の違うデータがあります。

    実績: タイトル / 説明 / 役割 / 期間 / 使用技術 / カテゴリ / 画像
    スキル: 名前 / 習熟度(10段階) / 説明 / カテゴリ

    これを本文にベタ書きすると、あとで「使用技術だけ一覧したい」といった扱いができません。ACF(Advanced Custom Fields)というプラグインを使うと、こうした項目を管理画面に専用の入力欄として追加できます。

    入力する側から見ると「役割」「期間」という欄が並ぶだけですが、データとしてはきちんと項目に分かれているので、表示側で自由に組み替えられます。

    3. 自分が慣れている

    実務でWordPressのテーマ制作をしてきたので、管理画面の構造もカスタム投稿タイプの考え方も分かっています。新しいことを1つ試すときは、他をなるべく既知のもので固める——今回はフロントエンド側で新しいことをたくさんやるので、CMS側は慣れたもので押さえておきたかった、という判断です。

    補足

    「学習目的だから全部新しくする」は失敗しやすいやり方です。問題が起きたとき、原因の候補が多すぎて切り分けられなくなります。実際この作業でも、原因を2回外して数時間を溶かしました。既知の部分が多いほど、その切り分けは楽になります。

    なぜNext.jsなのか

    表示側にはNext.jsを選びました。ReactというUIライブラリをベースにしたフレームワークです。

    選定理由は、サーバー側でデータを取ってHTMLを組み立てられる点にあります。

    Reactだけで作ると、ブラウザ側でJavaScriptが動いてからデータを取りに行く形が基本になります。この場合こういう流れです。

    【ブラウザ側で取得する場合】
    1. ブラウザが空っぽのHTMLを受け取る
    2. JavaScriptを読み込んで実行する
    3. そこからAPIにデータを取りに行く
    4. 返ってきたデータで画面を描く
       → 4に到達するまで、画面には何も出ない
       → 検索エンジンにも中身が伝わりにくい

    Next.jsのサーバーコンポーネントを使うと、この流れが変わります。

    【サーバー側で取得する場合】
    1. サーバーがWordPressからデータを取る
    2. サーバーが中身の入ったHTMLを組み立てる
    3. ブラウザは完成したHTMLを受け取る
       → 最初から中身が見える
       → 検索エンジンにも内容が伝わる

    ポートフォリオは検索から見つけてもらうことに意味があるサイトなので、この違いは重要でした。

    ちなみにこの選択が、後の大トラブルの前提にもなっています。サーバーがサーバーを呼ぶという通信経路ができるため、ブラウザから確認しても分からない問題が起きうるのです。実際、SSL証明書の不一致でデータが一切取れないという事態に陥りました。詳細は前回の記事に書いています。

    なぜGraphQLなのか(RESTではなく)

    WordPressには標準でREST APIという仕組みが備わっています。追加のプラグインなしで使えます。それでもGraphQLを選びました。

    RESTの場合に起きること

    REST APIは「URLごとに決まった内容が返ってくる」方式です。実績一覧が欲しければ、こう叩きます。

    GET /wp-json/wp/v2/project

    返ってくるのは、その投稿タイプが持つほぼ全部の項目です。使うのがタイトルと画像とカテゴリだけでも、更新日時も編集履歴のIDもコメント設定も付いてきます。

    さらに困るのが、関連するデータが別のリクエストになることです。

    1. 実績一覧を取る          → GET /wp/v2/project
    2. 各実績のカテゴリ名を取る  → GET /wp/v2/project_category?include=...
    3. 各実績の画像URLを取る     → GET /wp/v2/media?include=...
       → 画面ひとつ描くのに3往復

    GraphQLの場合

    GraphQLは「欲しい項目を書いて送ると、その形で返ってくる」方式です。

    query GetProjects {
      projects(first: 100) {
        nodes {
          title
          uri
          featuredImage { node { sourceUrl altText } }
          projectCategories { nodes { name slug } }
          projectDetail { role period toolsused }
        }
      }
    }

    これで1回のリクエストです。画像もカテゴリもACFの項目も、まとめて必要なぶんだけ返ってきます。

    決め手は型の自動生成だった

    ただ、往復回数の削減だけならRESTでも工夫でどうにかなります。決め手になったのは別の点でした。

    GraphQLにはスキーマという「どんなデータがどんな形で存在するか」の定義があり、これを機械が読み取れます。この性質を利用したのが GraphQL Codegen というツールです。

    npm run codegen

    これを実行すると、書いたクエリを読み取ってTypeScriptの型定義を自動生成してくれます。結果として、エディタ上でこうなります。

    project.  ← ここまで打つと、使える項目が候補に出る
    project.titel  ← 打ち間違えると即座に警告が出る

    ACFで項目を追加してWordPress側の構造が変わっても、コマンド1つで型が追従します。手で型を書き直す必要がありません。

    選定の勘所

    GraphQLの利点として真っ先に挙げられるのは「必要な項目だけ取れる」ことですが、個人サイト規模ではその差は体感できません。実際に効いたのは型の自動生成でした。 よく語られる利点と、自分の状況で効く利点は違うことがあります。

    なぜVercelなのか

    Next.jsを公開する場所として、Vercelを選びました。Next.jsを作っている会社が運営しているサービスです。

    理由は3つあります。

    1. GitHubに置くだけで公開される

    GitHubにコードをpushすると、Vercelが自動で検知してビルドし、公開まで済ませます。手作業のアップロードが要りません。今回の作業でも、修正をpushするたびに自動で本番へ反映されました。

    2. 設定がほぼ不要

    Next.jsのプロジェクトだと自動で判別され、必要な設定はほぼありません。実際に指定したのは2つだけでした。

    Root Directory : frontend
    環境変数        : NEXT_PUBLIC_WORDPRESS_API_URL

    3. 個人利用なら無料

    ポートフォリオ程度のアクセス数なら無料枠に収まります。独自ドメインの接続もSSL証明書の発行・更新も無料で自動です。

    SSL証明書は通常90日で期限が切れ、更新が必要です。これを忘れるとサイト全体が「安全でない接続」と表示されて事実上使えなくなります。Vercelはこれを自動でやってくれるので、運用の手間がひとつ消えます。

    なぜCMSは共用サーバーのままなのか

    ここは技術選定というより、現実的な事情です。

    WordPressをVercelに置くことはできません。PHPとデータベースが必要だからです。置き場所は別に必要になります。

    選択肢はいくつかありましたが、もともとお名前.comの共用サーバーを契約していて、旧サイトがそこで動いていました。新しく契約すれば月額が増えます。既存の契約で足りるなら、それでいい。

    ただし、この判断には代償がありました。

    実際に起きたこと

    共用サーバーは設定の自由度が低く、次の問題に当たりました。SSL証明書がホスト名と一致せずVercelから接続できない、PHPの設定ファイルがディレクトリごとにしか効かない、WordPressが /wp/ という予期しない場所にインストールされる、クエリ文字列付きのアクセスがWAFに遮断される。安く済ませたぶん、原因調査に時間を使いました。

    とはいえ、これらは調べれば分かる範囲の問題でした。学習目的という文脈では、むしろ良い題材だったとも思っています。

    この構成で払った代償

    ここが、この記事でいちばん書きたかった部分です。

    1. 管理する場所が2つになった

    静的サイトなら、置き場所は1つでした。いまは2つです。それぞれにドメイン、SSL証明書、更新、バックアップの管理が発生します。

    【静的サイト】 1箇所
    【今の構成】   Vercel + 共用サーバー = 2箇所
                   + GitHub(コードの置き場所)= 実質3箇所

    2. CMSが落ちるとサイトが空になる

    これがいちばん大きい代償です。表示側と、データを持つ側が分かれているということは、データ側が落ちると表示側は空を返すということです。

    実際、SSL証明書の問題でWordPressに接続できなかったとき、サイトは正常に表示されるのに実績もスキルも記事も1件も出ない状態になりました。しかもHTTPステータスは200なので、普通の死活監視では検知できません。

    静的サイトなら、こんなことは起きません。HTMLに中身が書いてあるからです。

    3. 分業ゆえの複雑さ

    ヘッドレス構成では「サーバーがサーバーを呼ぶ」経路ができます。この経路はブラウザからは見えません。

    従来:ブラウザ → WordPress
    今回:ブラウザ → Next.js(Vercel) → WordPress
    ※ 後半の「Next.js → WordPress」はサーバー同士の通信なので、ブラウザの開発者ツールからは確認できない。

    今回、ブラウザからは正常にアクセスできるのにサーバーからは失敗する、という状況で数時間はまりました。確認方法そのものを間違えていたわけです。この落とし穴は、構成を分けたことの直接の帰結です。

    4. ビルドという工程が増えた

    静的サイトはファイルを置けば公開できました。いまはビルドが必要で、そこが失敗すると公開できません。実際、最初のデプロイは自動生成ファイルの不足で失敗しています。

    静的サイト 今の構成
    更新のしやすさ ×(HTML直接編集) ◎(管理画面から)
    表示側の自由度 ◎ ◎
    管理する場所 1つ 3つ
    壊れにくさ ◎ △(CMSに依存)
    公開までの手数 ファイルを置くだけ ビルドが必要
    学べること 少ない 多い

    選ばなかった選択肢

    静的サイトジェネレータ + Markdown

    AstroやHugoなどで、記事をMarkdownファイルとして書く方式です。CMSが不要になるので、構成は圧倒的にシンプルになります。管理場所も減り、CMSが落ちる心配もありません。

    選ばなかったのは、記事を書く体験を落としたくなかったからです。画像の挿入、下書きの管理、公開日の設定——このあたりはWordPressの管理画面のほうが圧倒的に楽です。文章を書くこと自体のハードルは下げておきたい、と考えました。

    ヘッドセスCMS(microCMS / Contentful など)

    最初からヘッドレス前提のCMSです。サーバーの管理が不要になるのが大きな利点で、今回はまったSSL証明書やPHP設定の問題は最初から発生しません。

    選ばなかった理由は、既にWordPressの資産(記事・画像・構造)があったこと、そして自分が慣れていることです。ゼロから始めるなら、こちらのほうが素直だと思います。

    WordPressのテーマとして普通に作る

    冒頭に書いた案Aです。この規模では、いちばん妥当な選択だったと思います。管理場所は1つ、ビルド不要、CMSが落ちればサイトも落ちるという単純明快な構造。

    選ばなかったのは学習目的が大きいのですが、正直に言えば「新しい構成を試したかった」という動機が最初にあり、理屈は後から整理した部分もあります。

    この構成が向いている人

    一通りやってみて、こう思っています。

    向いている

    フロントエンドの表示を完全に自分で制御したい人。React/Next.jsを実践的に学びたい人。すでにWordPressの資産と運用の慣れがある人。サイトが一時的に壊れても困らない立場の人。

    向いていない

    とにかく早く公開したい人。運用に手をかけられない人。止まると業務に影響が出るサイトを作る人。管理場所が増えることを許容できない人。

    最後の点が重要だと思っています。今回の構成は部品が増えたぶん、壊れる箇所も増えました。実際、公開までにSSL証明書、DNS、環境変数、ビルド設定と、それぞれ別の理由で止まっています。

    そのすべてが学びになったのは、これが止まっても誰も困らない自分のサイトだったからです。同じ構成を仕事で採用するなら、この「壊れる箇所の多さ」を運用体制で引き受けられるかどうかが判断の分かれ目になります。

    技術選定に唯一の正解はありませんが、何を得て、代わりに何を失ったのかを説明できる状態にはしておきたい——今回それを整理してみて、そう感じました。