PHP を上げられない WordPress で Contact Form 7 の最新版を使う:WASM で動かす実験と、iframe という答え

PHP を上げられない WordPress で CF7 最新版。WASM で動かす実験と、外部サーバー+iframe でなじませる答え

約 41,028 字 · 読むのに 68 分ほど

Contact Form 7 は 6.2 から PHP 8.3 以上が必要になりました。PHP を自分で上げられない WordPress では、最新版に更新できません。そこで、CF7 だけを WASM(WebAssembly)の PHP 8.3 で動かせないか、サーバーの Node で動かす方法と、訪問者のブラウザーで動かす方法を試しました。結論から言うと、どちらも動きましたが、おすすめは PHP 8.3 が動く外部サーバーの CF7 を iframe で埋め込む方法です。

CF7 6.2 から PHP 8.3 が必要に

Contact Form 7必要な PHP必要な WordPress
6.1.7 まで7.46.7
6.2 / 6.2.1 / 6.2.28.37.1

レンタルサーバーなら、管理画面で PHP のバージョンを上げれば済みます。困るのは、PHP を自分で選べない WordPress です。たとえば EC サービスに付属している WordPress では、PHP 8 系にすら上げられないことがあります。この場合、CF7 は 6.1.7 で止まり、今後のセキュリティ更新を受けられなくなります。

まず確かめた:本当に PHP 8.3 が要るのか

WordPress Playground の CLI で、WASM の PHP 8.1 に CF7 6.2.2 を入れて試しました。PHP 8.1 では、WordPress が有効化を拒み、同梱の Composer の確認でも読み込みが止まります。この二つの確認だけを外したテスト用の複製では、次のとおり動きました。

  • 全 159 ファイルを PHP 8.1 の構文で解析して、エラーは 0 件
  • PHP 8.2 以降でしか使えない関数や書き方も見つからなかった
  • フォームの表示、入力チェック、送信(メールの作成まで)が動いた

つまり、今の 6.2.2 の中身は PHP 8.1 でも動きます。PHP 8.3 は作者が決めた条件です。ただ、今後の版で 8.3 の機能が使われれば、その時点で壊れます。確認を外して本番で使うのはおすすめしません。そこで、CF7 だけを PHP 8.3 で動かす方法を考えました。

実験 1:サーバーの Node で WASM の PHP 8.3 を動かす

サイト本体は PHP 8.1 のままにして、CF7 だけを、サーバーの Node 上の php-wasm(PHP 8.3)で動かします。本体には小さな「つなぎ役」のプラグインを入れ、次の二つだけを WASM 側に渡します。

  • [contact-form-7] のショートコード(フォームの HTML)
  • CF7 の REST(contact-form-7/v1。入力ルールの取得、送信、送信後の更新)

WASM 側は、CF7 だけを入れた小さな WordPress(DB は SQLite)です。リクエストのたびに Node が PHP 8.3 を起動し、結果を JSON で返します。CF7 が作ったメールは WASM 側では送らず、本体の wp_mail() で送ります。

項目結果
フォームの表示初回 1.2 秒、2 回目からは 0.05 秒(HTML を 1 時間キャッシュ)
送信約 1 秒
10 件同時の送信全件成功(1.2〜15.3 秒。1 件ずつ順番に処理)
PHP 8.1 の WordPress のページに、WASM の PHP 8.3 で動く Contact Form 7 のフォームが表示されたところ
PHP 8.1 のサイトに出た、WASM の PHP 8.3 の CF7 のフォーム。実験 2 のブラウザー版も見た目は同じ

Xserver の検証用サーバーでも試しました。ここで二つつまずきました。

  • OS が古く(CentOS 7、glibc 2.17)、公式の Node 22 が動かない。Node.js の非公式ビルド(glibc 2.17 向け)で動いた
  • php-wasm がファイルのロックに使うネイティブモジュールが読み込めない。何もしない代わりの部品に差し替え、本体側で 1 件ずつ順番に処理させて回避した

また、5 件同時に送った最初の 1 回だけ、全件が失敗しました。その後は 3 回試して再現せず、原因はわかっていません。

実験 2:訪問者のブラウザーで WASM の PHP 8.3 を動かす

Node も使えないサーバー向けに、CF7 をブラウザーの中で動かす版も作りました。

  • フォームの HTML は事前に作っておき、ページにすぐ出す
  • ページを開くと、裏で PHP 8.3 と CF7 入りの小さな WordPress をブラウザーに読み込む
  • CF7 の JS の通信を window.fetch で横取りして、ブラウザーの中の PHP で処理する
  • CF7 が作った通知メールだけをサーバーの中継口に送り、サーバーが wp_mail() で送る
項目結果
初回のダウンロード約 12.5 MB(PHP の WASM 6.8 MB、WordPress と CF7 一式 5.7 MB)
最初の入力チェック1〜2.3 秒(PHP の起動を含む)
送信約 0.56 秒

Xserver は .wasm を圧縮せずに送るため、そのままでは毎回 18 MB を読み込みます。gzip で圧縮したファイルを用意し、ブラウザーで受け取るようにして 6.8 MB に減らしました。

ただし、ブラウザーの中で動くということは、入力チェックをすり抜けられるということです。中継口では、宛先をサーバー側で固定し、ブラウザーから受け取るヘッダーは Reply-To だけにし、nonce の確認と IP ごとの回数制限を入れました。それでも、自動返信、ファイルの添付、reCAPTCHA や Akismet のサーバーでの確認、受信の保存は使えません。Firefox と Safari では、まだ確かめていません。

本命:PHP 8.3 の外部サーバーで動かして iframe で埋め込む

WASM の二つの方法は動きましたが、どちらも仕組みが複雑で、CF7 の機能の一部が使えません。もっと素直なのは、CF7 は PHP 8.3 が動く別のサーバーで普通に動かし、ショップのページには iframe で埋め込む方法です。

CF7 には、別のサイトに埋め込む機能はありません。そこで外部サーバーの WordPress に、フォームだけを表示するページを出す小さなプラグインを置きました。?cf7_embed=フォームID を開くと、ヘッダーやフッターのないフォームだけのページになります。

ショップのページには、次の 2 行を貼るだけです。ショップ側にプラグインは要りません。

<iframe data-cf7-embed src="https://forms.example.com/?cf7_embed=6" title="お問い合わせフォーム"></iframe>
<script src="https://forms.example.com/wp-content/plugins/cf7-embed/parent.js?v=0.4.0" async></script>

?v= は版番号です。サーバーが parent.js をキャッシュしていると、更新しても古い版が返り続けるため、更新のたびに上げます。

iframe で困りがちな二つのことは、この仕組みで解決しています。

  • 高さ:フォームの高さが変わるたびに postMessage で親のページに伝え、iframe の高さを合わせる。エラー表示が出ても、iframe の中でスクロールしない
  • デザイン:親のページのスクリプトが、iframe の近くの本文の書体・大きさ・色、背景色、ページ内のボタンと入力欄の色や角の丸みを読み取って、フォームに渡す。フォームは外部サーバーのテーマの CSS を外し、受け取った値だけで描くので、ショップのデザインに自動でなじむ

Web フォントは、別ドメインの iframe からは CORS の制限で読めないことが多いので、親のページが自分でフォントのファイルを取得して、中身を iframe に渡しています。背景が暗いページでは、エラーの赤を明るくして読みやすくしています。埋め込んでよいサイトは Content-Security-Policy: frame-ancestors で限り、デザインや高さのやり取りもそのサイトとだけ行います。

iframe の中のフォームが、外部サーバーのテーマのままで表示されたところ
なじませる前。iframe の中は外部サーバーのテーマで描かれ、入力欄もボタンも素っ気ない
iframe の中のフォームが、埋め込んだページの書体とボタンの色に合わせて表示されたところ
なじませた後。書体、入力欄、黒いボタンが埋め込み先のテーマに合っている

PHP 8.1 の WordPress のページに、Xserver の検証用サーバー(PHP 8.3、CF7 6.2.2)のフォームを埋め込んで試しました。

項目結果
フォームの表示約 0.34 秒
高さの自動調整エラー表示で 658 px から 731 px に広がり、iframe も追従
デザイン白地のテーマでも、深緑の背景・オレンジの丸いボタンのページでも、書体・色・ボタンの形が自動でなじんだ
送信約 0.16〜0.33 秒
許可していないサイトからの埋め込みブラウザーが拒否し、フォームは表示されない
スマートフォンの幅で、iframe の中のフォームが画面の幅に合わせて表示されたところ
スマートフォンの幅(390 px)でも、iframe の高さと幅が合う

CF7 は外部サーバーで普通に動いているので、自動返信、ファイルの添付、reCAPTCHA、受信の保存(Flamingo)もそのまま使えます。CF7 の更新も、外部サーバーの管理画面でいつもどおりに行えます。

サーバーがもう 1 台要る。その代わり、ショップの制限に左右されない

この方法の負担は、PHP 8.3 が動くサーバーをもう 1 台用意することです。契約の費用がかかり、WordPress と CF7 の更新、バックアップ、メールの送信設定といった管理も 1 台分増えます。

その代わり、フォームはショップの今の構成やサーバーの制限からまったく切り離されます。

  • ショップの PHP のバージョンに関係なく、CF7 の最新版を使い続けられる。PHP 8 系に上げられない環境でもよい
  • ショップ側にプラグインも Node も SSH も要らない。HTML を貼れれば使える
  • ショップのサービスを乗り換えたり、ショップの WordPress を作り直したりしても、フォームと受信の記録はそのまま残る
  • 1 台のフォーム用サーバーから、複数のショップやページにフォームを配れる
  • フォームまわりの穴や負荷が、ショップ本体に及ばない

WASM の 2 つの方法は「サーバーを増やさない」代わりに、Node が動くか、プラグインを入れられるかといった、ショップのサーバーの条件に縛られます。サーバーを 1 台増やせるなら、iframe のほうが条件を選びません。

実際のショップのデザインになじむか

テーマの標準のページだけでは物足りないので、ヘッダー(検索欄)、商品カード(カートのボタン)、お問い合わせの欄、フッター(メールマガジンの入力欄)がある、実際に近いショップのページを 5 種類作って埋め込みました。同じフォーム、同じ 2 行で、次のように変わります。

ATELIER MONO(白地、四角い黒ボタン、下線だけの入力欄、大文字で字間の広いラベル)のページに、同じデザインで埋め込まれたお問い合わせフォーム
ATELIER MONO:白地、四角い黒ボタン、下線だけの入力欄、大文字で字間の広いラベル
こめと暮らし(ベージュの地、緑の角丸ボタン、塗りつぶしの入力欄)のページに、同じデザインで埋め込まれたお問い合わせフォーム
こめと暮らし:ベージュの地、緑の角丸ボタン、塗りつぶしの入力欄
POPCORN TOYS(水色の地、太い枠と影のある丸いボタン、太枠の入力欄)のページに、同じデザインで埋め込まれたお問い合わせフォーム
POPCORN TOYS:水色の地、太い枠と影のある丸いボタン、太枠の入力欄
NOIR WATCH(黒地、枠線だけの金のボタン、暗い入力欄)のページに、同じデザインで埋め込まれたお問い合わせフォーム
NOIR WATCH:黒地、枠線だけの金のボタン、暗い入力欄
まいにち電器(白地、赤いグラデーションのボタン、内側に影のある入力欄)のページに、同じデザインで埋め込まれたお問い合わせフォーム
まいにち電器:白地、赤いグラデーションのボタン、内側に影のある入力欄
NOIR WATCH のページで、フォームの入力エラーが表示されたところ
エラー表示も、ラベルの作り(小さな大文字)に引きずられず、本文の大きさで出る

最初からうまくいったわけではありません。試すうちに、次の崩れが見つかりました。

崩れ原因直し方
ラベルが白くなって読めないお問い合わせ欄の近くにラベルがなく、黒いフッターの白いラベルを拾ったiframe と同じ背景の上の部品を優先する。背景が違う部品からは色を取らない
ボタンが背景に溶けて消えるフッターのボタンが、たまたまお問い合わせ欄と同じ色だったフォームの背景の上で見分けがつくボタンだけを手本にする
エラー文が小さすぎるCF7 はエラー文をラベルの中に入れるので、小さな大文字のラベルの大きさを受け継ぐエラー文は本文の大きさと書体で出す
フォーカスの枠が浮く下線だけの入力欄に、四角い枠が付いたフォーカス中は、そのサイトの枠線の色をアクセント色に変えるだけにする
更新しても直らないサーバーが parent.js をキャッシュしていた貼るコードに版番号(?v=)を付ける

自動で選んだ手本が合わないときは、ショップ側で手本にする部品を指定できるようにしました。

<iframe data-cf7-embed data-cf7-button=".btn-primary" data-cf7-input="#newsletter"
        data-cf7-label=".field label" data-cf7-text=".description" src="…"></iframe>

近くにボタンがないときは、OKLCH で明るさだけ直す

ページにボタンも入力欄もないと、リンクの色などから色を推測するしかありません。そのリンクが淡い黄色だったり、暗い背景に暗い青だったりすると、ボタンが背景に溶けたり、ボタンの文字が読めなくなったりします。

そこで、色を OKLCH に変換し、色合い(色相)と鮮やかさ(彩度)はそのままに、明るさ(L)だけを、背景との差が足りるところまで動かすようにしました。目安は WCAG に合わせて、文字は 4.5:1、ボタンの塗りや枠線は 3:1 です。ページから実際に拾ったボタンの文字色はサイトのデザインなので、太字のボタン文字として 3:1 まで認めます。差がもともと足りている色には何もしないので、上の 5 種類の見た目は変わりません。

ボタンも入力欄もないショップ 2 つで、色の補正をしない場合とする場合のボタンの周りを並べたところ
左が補正なし、右が補正あり。淡い黄色のボタンは同じ色合いの濃い黄土色に、背景に沈んでいた暗い青は明るい青になる

CSS にも contrast-color() や相対色構文(oklch(from …))がありますが、前者は白か黒しか返さず、後者では「差が足りるまで」という条件を書けません。計算は JS で行い、結果を CSS 変数に入れています。なお、黄色は暗くすると黄土色になり、印象が変わります。淡い色がブランドの色なら、手本のボタンを指定するほうがきれいに合います。

なじませるコード

仕組みは 2 つのファイルでできています。外部サーバーの WordPress に置くプラグイン cf7-embed.php(フォームだけのページを出し、受け取った見た目で描く)と、ショップのページに読み込む parent.js(ページの見た目を読み取って渡す)です。やり取りは次の順です。

  1. iframe の中のフォームが、親のページに「準備できた」と postMessage で知らせる(フォームは見た目が届くまで隠しておく)
  2. parent.js が、iframe の近くから手本の本文・ボタン・入力欄・ラベルを選び、getComputedStyle で見た目を読み取って送り返す。書体のファイルも一緒に渡す
  3. フォームは受け取った値を CSS 変数に入れ、OKLCH の補正をかけてから表示する
  4. フォームの高さが変わるたびに、親のページに高さを伝える

手本の部品を選ぶ(parent.js)

一番の要は、どの部品を手本にするかです。iframe の近くにあるもの、iframe と同じ背景の上にあるものを優先し、ボタンはフォームの背景の上で見分けがつくものだけを選びます。前の章の崩れのうち 2 つは、ここで直しました。

手本の部品を選ぶコードを見る(42 行)
// The background an element sits on (nearest opaque ancestor, not the element itself).
function bgOf(el) {
  for (el = el && el.parentElement; el && el.nodeType === 1; el = el.parentElement) {
    var b = getComputedStyle(el).backgroundColor;
    if (opaque(b)) return b;
  }
  return 'rgb(255, 255, 255)';
}
// Elements matching sel near the iframe first, else on the whole page; among them,
// those on the same background as the iframe come first (a footer on a dark band
// is styled for that band, not for the form's).
function candidates(frame, sel) {
  var near = frame.closest('section, .wp-block-group, article, main, form, footer') || document;
  var list = Array.prototype.filter.call(near.querySelectorAll(sel), visible);
  if (!list.length) list = Array.prototype.filter.call(document.querySelectorAll(sel), visible);
  list = list.filter(function (el) { return !el.closest('iframe') && el !== frame; });
  var bg = bgOf(frame);
  var same = list.filter(function (el) { return bgOf(el) === bg; });
  return same.concat(list.filter(function (el) { return same.indexOf(el) < 0; }));
}
function explicit(frame, attr) {
  var sel = frame.getAttribute(attr);
  return sel ? document.querySelector(sel) : null;
}
function side(s, k) {
  return s['border' + k + 'Width'] + ' ' + s['border' + k + 'Style'] + ' ' + s['border' + k + 'Color'];
}

// The most prominent button: one with a fill, gradient or border; largest wins.
function pickButton(frame) {
  var frameBg = bgOf(frame);
  var list = candidates(frame, 'button, input[type=submit], .wp-element-button, .wp-block-button__link, a.button, a.btn, .btn, [role=button]')
    .filter(function (el) {
      var s = getComputedStyle(el);
      var border = parseFloat(s.borderTopWidth) > 0 && s.borderTopColor !== frameBg;
      var fill = (opaque(s.backgroundColor) && s.backgroundColor !== frameBg) || s.backgroundImage !== 'none';
      // It must stand out on the form's background, or the form's button would vanish.
      return (el.textContent || el.value || '').trim() && (fill || border);
    });
  list.sort(function (a, b) { return (bgOf(b) === frameBg) - (bgOf(a) === frameBg) || area(b) - area(a); });
  return list[0];
}

受け取った値で描く(cf7-embed.php)

フォーム側は、外部サーバーのテーマの CSS を外し、CF7 の部品を CSS 変数だけで描きます。入力欄の枠線を上下左右で別々に持つので、下線だけの入力欄もそのまま再現できます。

フォームを描く CSS を見る(18 行)
.wpcf7 input:not([type=submit]):not([type=checkbox]):not([type=radio]):not([type=hidden]),
.wpcf7 textarea, .wpcf7 select {
  display: block; box-sizing: border-box; width: 100%; margin-top: .4em; padding: var(--cf7e-in-pad);
  font-family: var(--cf7e-font); font-size: var(--cf7e-size); letter-spacing: normal; text-transform: none;
  color: var(--cf7e-in-text); background: var(--cf7e-in-bg);
  border-top: var(--cf7e-in-top); border-right: var(--cf7e-in-right); border-bottom: var(--cf7e-in-bottom); border-left: var(--cf7e-in-left);
  border-radius: var(--cf7e-in-radius); box-shadow: var(--cf7e-in-shadow);
}
.wpcf7 textarea { min-height: 9em; resize: vertical; }
.wpcf7 :is(input, textarea, select):not([type=submit]):focus { outline: 2px solid transparent; border-color: var(--cf7e-accent); } /* the site's own borders, in the accent color */
.wpcf7 input[type=submit]:focus-visible { outline: 2px solid var(--cf7e-accent); outline-offset: 3px; }
.wpcf7 input[type=submit] {
  font-family: var(--cf7e-btn-font); font-size: var(--cf7e-btn-size); font-weight: var(--cf7e-btn-weight);
  letter-spacing: var(--cf7e-btn-spacing); text-transform: var(--cf7e-btn-transform);
  padding: var(--cf7e-btn-pad); color: var(--cf7e-btn-text);
  background-color: var(--cf7e-btn-bg); background-image: var(--cf7e-btn-image);
  border: var(--cf7e-btn-border); border-radius: var(--cf7e-radius); box-shadow: var(--cf7e-btn-shadow); cursor: pointer;
}

OKLCH で明るさだけ動かす(cf7-embed.php)

色を OKLCH に変換し、明るさ(L)だけを 0.01 ずつ、暗くする方向と明るくする方向の両方に動かして、先に基準を満たしたほうを採ります。どちらでも満たせなければ、白か黒にします。

// fg with its OKLCH lightness moved (the shorter way that works) until ratio(fg, bg) >= min.
function fix(fg, bg, min) {
  if (ratio(fg, bg) >= min) return fg;
  var o = toOklch(fg), best = null;
  [-1, 1].forEach(function (dir) {
    for (var i = 1; i <= 100; i++) {
      var L = o[0] + dir * i * 0.01;
      if (L < 0 || L > 1) break;
      var c = fromOklch([L, o[1], o[2]]);
      if (ratio(c, bg) >= min) { if (!best || i < best.i) best = { i: i, c: c }; break; }
    }
  });
  return best ? best.c : (ratio([0, 0, 0], bg) > ratio([255, 255, 255], bg) ? [0, 0, 0] : [255, 255, 255]);
}

全文

コードは WordPress と同じ GPL-2.0-or-later で公開します。自由に使い、改変し、配布できます(配布するときは同じライセンスで)。

使うときは、cf7-embed.php と parent.js を外部サーバーの wp-content/plugins/cf7-embed/ に置いてプラグインを有効にし、wp-config.php に埋め込んでよいサイトを書きます。

define( 'CF7_EMBED_PARENTS', 'https://shop.example.com' );
parent.js(ショップのページで読み込む・216 行)
/**
 * CF7 Embed — script for the page that embeds the form.
 * License: GPL-2.0-or-later (https://www.gnu.org/licenses/gpl-2.0.html)
 *
 *   <iframe data-cf7-embed src="https://forms.example.com/?cf7_embed=6" title="お問い合わせ"></iframe>
 *   <script src="https://forms.example.com/wp-content/plugins/cf7-embed/parent.js" async></script>
 *
 * Reads this page's look around each iframe and hands it to the form so it
 * blends in, and resizes the iframe to the form's height.
 *
 * What is read (nearest to the iframe first, then the whole page):
 *   text    font, size, line height, color      (body text near the iframe)
 *   bg      background color behind the iframe
 *   button  colors / gradient, border, radius, padding, font, letter spacing,
 *           upper case, shadow                   (the most prominent button)
 *   input   each side's border (underline-only works), radius, background,
 *           padding, shadow                      (the widest text field)
 *   label   color, weight, size, letter spacing, upper case
 *   fonts   @font-face of this site (passed as data) and web font style sheets
 *           (Google Fonts, Adobe Fonts, Bunny Fonts; loaded by the form itself)
 *
 * When the automatic choice is wrong, point at the right elements:
 *   <iframe data-cf7-embed data-cf7-button=".btn-primary" data-cf7-input="#newsletter-email"
 *           data-cf7-label=".field label" data-cf7-text=".product-description" ...>
 *
 * Colors that would not show (a pale brand color, a guess from a link) are corrected in
 * the form by moving only their OKLCH lightness. data-cf7-contrast="strict" also gives
 * the fields WCAG 1.4.11 (3:1) boundaries.
 */
(function () {
  var origin = new URL(document.currentScript.src).origin;
  var FONT_CSS_HOSTS = /^https:\/\/(fonts\.googleapis\.com|use\.typekit\.net|fonts\.bunny\.net)\//;

  function frames() {
    return Array.prototype.slice.call(document.querySelectorAll('iframe[data-cf7-embed]'));
  }
  function opaque(c) {
    return !!c && c !== 'transparent' && !/rgba\([^)]*,\s*0\)$/.test(c);
  }
  function visible(el) {
    var r = el.getBoundingClientRect();
    return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
  }
  function area(el) {
    var r = el.getBoundingClientRect();
    return r.width * r.height;
  }
  // The background an element sits on (nearest opaque ancestor, not the element itself).
  function bgOf(el) {
    for (el = el && el.parentElement; el && el.nodeType === 1; el = el.parentElement) {
      var b = getComputedStyle(el).backgroundColor;
      if (opaque(b)) return b;
    }
    return 'rgb(255, 255, 255)';
  }
  // Elements matching sel near the iframe first, else on the whole page; among them,
  // those on the same background as the iframe come first (a footer on a dark band
  // is styled for that band, not for the form's).
  function candidates(frame, sel) {
    var near = frame.closest('section, .wp-block-group, article, main, form, footer') || document;
    var list = Array.prototype.filter.call(near.querySelectorAll(sel), visible);
    if (!list.length) list = Array.prototype.filter.call(document.querySelectorAll(sel), visible);
    list = list.filter(function (el) { return !el.closest('iframe') && el !== frame; });
    var bg = bgOf(frame);
    var same = list.filter(function (el) { return bgOf(el) === bg; });
    return same.concat(list.filter(function (el) { return same.indexOf(el) < 0; }));
  }
  function explicit(frame, attr) {
    var sel = frame.getAttribute(attr);
    return sel ? document.querySelector(sel) : null;
  }
  function side(s, k) {
    return s['border' + k + 'Width'] + ' ' + s['border' + k + 'Style'] + ' ' + s['border' + k + 'Color'];
  }

  // The most prominent button: one with a fill, gradient or border; largest wins.
  function pickButton(frame) {
    var frameBg = bgOf(frame);
    var list = candidates(frame, 'button, input[type=submit], .wp-element-button, .wp-block-button__link, a.button, a.btn, .btn, [role=button]')
      .filter(function (el) {
        var s = getComputedStyle(el);
        var border = parseFloat(s.borderTopWidth) > 0 && s.borderTopColor !== frameBg;
        var fill = (opaque(s.backgroundColor) && s.backgroundColor !== frameBg) || s.backgroundImage !== 'none';
        // It must stand out on the form's background, or the form's button would vanish.
        return (el.textContent || el.value || '').trim() && (fill || border);
      });
    list.sort(function (a, b) { return (bgOf(b) === frameBg) - (bgOf(a) === frameBg) || area(b) - area(a); });
    return list[0];
  }
  function pickInput(frame) {
    var list = candidates(frame, 'input[type=text], input[type=email], input[type=search], input[type=tel], input:not([type]), textarea');
    var bg = bgOf(frame);
    list.sort(function (a, b) { return (bgOf(b) === bg) - (bgOf(a) === bg) || b.getBoundingClientRect().width - a.getBoundingClientRect().width; });
    return list[0];
  }

  function tokens(frame) {
    var host = frame.parentElement;
    var sample = explicit(frame, 'data-cf7-text') || candidates(frame, 'p').filter(function (p) { return p.textContent.trim().length > 10; })[0] || host;
    var cs = getComputedStyle(sample);
    var bg = 'rgb(255, 255, 255)';
    for (var el = host; el; el = el.parentElement) {
      var b = getComputedStyle(el).backgroundColor;
      if (opaque(b)) { bg = b; break; }
    }
    var t = { font: cs.fontFamily, size: cs.fontSize, line: cs.lineHeight, text: cs.color, bg: bg };
    if (frame.getAttribute('data-cf7-contrast') === 'strict') t.strict = true; // fields get WCAG 3:1 boundaries

    var btn = explicit(frame, 'data-cf7-button') || pickButton(frame);
    if (btn) {
      var bs = getComputedStyle(btn);
      var filled = opaque(bs.backgroundColor) || bs.backgroundImage !== 'none';
      t.btnBg = opaque(bs.backgroundColor) ? bs.backgroundColor : 'transparent';
      t.btnImage = /gradient\(/.test(bs.backgroundImage) ? bs.backgroundImage : 'none';
      t.btnText = bs.color;
      t.btnBorder = parseFloat(bs.borderTopWidth) > 0 ? side(bs, 'Top') : 'none';
      t.radius = bs.borderRadius;
      t.btnPad = bs.padding;
      t.btnWeight = bs.fontWeight;
      t.btnFont = bs.fontFamily;
      t.btnSize = bs.fontSize;
      t.btnSpacing = bs.letterSpacing;
      t.btnTransform = bs.textTransform;
      t.btnShadow = bs.boxShadow;
      t.accent = filled && opaque(bs.backgroundColor) ? bs.backgroundColor : (parseFloat(bs.borderTopWidth) > 0 ? bs.borderTopColor : bs.color);
    } else {
      var link = candidates(frame, 'a[href]')[0];
      if (link) t.accent = getComputedStyle(link).color;
    }

    var input = explicit(frame, 'data-cf7-input') || pickInput(frame);
    if (input) {
      var is = getComputedStyle(input);
      t.inTop = side(is, 'Top');
      t.inRight = side(is, 'Right');
      t.inBottom = side(is, 'Bottom');
      t.inLeft = side(is, 'Left');
      t.inRadius = is.borderRadius;
      t.inBg = opaque(is.backgroundColor) ? is.backgroundColor : 'transparent';
      t.inPad = is.padding;
      t.inShadow = is.boxShadow;
      t.inText = is.color;
    }

    var label = explicit(frame, 'data-cf7-label') || candidates(frame, 'label')[0];
    if (label) {
      var ls = getComputedStyle(label);
      if (bgOf(label) === bg) t.lbColor = ls.color; // a label on another background has the wrong color for the form
      t.lbWeight = ls.fontWeight;
      t.lbSize = ls.fontSize;
      t.lbSpacing = ls.letterSpacing;
      t.lbTransform = ls.textTransform;
      t.lbFont = ls.fontFamily;
    }
    return t;
  }

  // @font-face rules of this site (readable sheets) for the families in use; passed as data.
  function fontFaces(families) {
    var out = [];
    Array.prototype.forEach.call(document.styleSheets, function (sheet) {
      var rules;
      try { rules = sheet.cssRules; } catch (e) { return; } // cross-origin sheet
      Array.prototype.forEach.call(rules || [], function (r) {
        if (r.type !== CSSRule.FONT_FACE_RULE) return;
        var fam = r.style.getPropertyValue('font-family').replace(/["']/g, '').trim();
        if (families.indexOf(fam.toLowerCase()) < 0) return;
        var m = /url\(["']?([^"')]+)["']?\)/.exec(r.style.getPropertyValue('src'));
        if (!m) return;
        out.push({
          family: fam,
          url: new URL(m[1], sheet.href || document.baseURI).href,
          weight: r.style.getPropertyValue('font-weight') || 'normal',
          style: r.style.getPropertyValue('font-style') || 'normal',
        });
      });
    });
    return out.slice(0, 8);
  }
  // Web font services serve their fonts with CORS, so the form can load their CSS itself.
  function fontLinks() {
    return Array.prototype.map.call(document.querySelectorAll('link[rel=stylesheet][href]'), function (l) { return l.href; })
      .filter(function (h) { return FONT_CSS_HOSTS.test(h); }).slice(0, 4);
  }

  function send(frame) {
    var t = tokens(frame);
    var families = [t.font, t.btnFont, t.lbFont].join(',').split(',').map(function (f) {
      return f.replace(/["']/g, '').trim().toLowerCase();
    });
    var faces = fontFaces(families);
    Promise.all(faces.map(function (f) {
      return fetch(f.url).then(function (r) { return r.ok ? r.arrayBuffer() : null; }).then(function (data) {
        return data ? { family: f.family, weight: f.weight, style: f.style, data: data } : null;
      }, function () { return null; });
    })).then(function (fonts) {
      frame.contentWindow.postMessage({ type: 'cf7-embed-style', tokens: t, fonts: fonts.filter(Boolean), fontLinks: fontLinks() }, origin);
    });
  }

  window.addEventListener('message', function (e) {
    if (e.origin !== origin || !e.data) return;
    frames().forEach(function (frame) {
      if (frame.contentWindow !== e.source) return;
      if (e.data.type === 'cf7-embed-ready') send(frame);
      if (e.data.type === 'cf7-embed-height') frame.style.height = e.data.height + 'px';
    });
  });

  frames().forEach(function (frame) {
    frame.style.width = '100%';
    frame.style.border = '0';
    frame.style.display = 'block';
    frame.style.colorScheme = 'normal';
  });
})();
cf7-embed.php(外部サーバーのプラグイン・313 行)
<?php
/**
 * Plugin Name: CF7 Embed
 * Description: Contact Form 7 のフォームだけのページを出し、別のサイトに iframe で埋め込めるようにする。?cf7_embed=<フォーム ID>。埋め込み先のデザイン(書体・色・ボタン・入力欄)に自動でなじむ。
 * Version: 0.4.0
 * License: GPL-2.0-or-later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 *
 * wp-config.php で設定する:
 *   CF7_EMBED_PARENTS  埋め込んでよいサイトのオリジン(空白区切り)例: 'https://shop.example.com'
 *
 * 埋め込み先に貼るもの:
 *   <iframe data-cf7-embed src="https://このサイト/?cf7_embed=6" title="お問い合わせ"></iframe>
 *   <script src="https://このサイト/wp-content/plugins/cf7-embed/parent.js" async></script>
 */

defined( 'ABSPATH' ) || exit;

add_action(
  'template_redirect',
  function () {
    if ( ! isset( $_GET['cf7_embed'] ) || ! function_exists( 'wpcf7_contact_form' ) ) {
      return;
    }
    $form = wpcf7_contact_form( absint( $_GET['cf7_embed'] ) );
    if ( ! $form ) {
      status_header( 404 );
      exit;
    }
    $parents = defined( 'CF7_EMBED_PARENTS' ) ? preg_split( '/\s+/', trim( CF7_EMBED_PARENTS ) ) : array();

    // Only the listed sites may frame this page; styles and heights are only exchanged with them.
    header( "Content-Security-Policy: frame-ancestors 'self' " . implode( ' ', array_map( 'esc_url_raw', $parents ) ) );
    header_remove( 'X-Frame-Options' );
    nocache_headers();
    show_admin_bar( false );

    // This site's theme must not style the form; the embedding page's look is applied instead.
    remove_action( 'wp_enqueue_scripts', 'wp_enqueue_global_styles' );
    remove_action( 'wp_footer', 'wp_enqueue_global_styles', 1 );
    remove_action( 'wp_enqueue_scripts', 'wp_enqueue_classic_theme_styles' );
    remove_action( 'wp_footer', 'wp_enqueue_stored_styles', 1 );
    remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
    remove_action( 'wp_print_styles', 'print_emoji_styles' );
    add_action(
      'wp_enqueue_scripts',
      function () {
        foreach ( wp_styles()->queue as $handle ) {
          if ( 'contact-form-7' !== $handle ) {
            wp_dequeue_style( $handle );
          }
        }
      },
      PHP_INT_MAX
    );
    ?>
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<?php wp_head(); ?>
<style>
:root {
  --cf7e-font: system-ui, -apple-system, "Segoe UI", sans-serif;
  --cf7e-size: 16px;
  --cf7e-line: 1.6;
  --cf7e-text: #1e1e1e;
  --cf7e-bg: #fff;
  --cf7e-accent: #1e1e1e;
  --cf7e-error: #d63638;
  /* button */
  --cf7e-btn-bg: var(--cf7e-accent);
  --cf7e-btn-image: none;
  --cf7e-btn-text: #fff;
  --cf7e-btn-border: 0;
  --cf7e-radius: 4px;
  --cf7e-btn-pad: .7em 1.6em;
  --cf7e-btn-weight: 600;
  --cf7e-btn-font: var(--cf7e-font);
  --cf7e-btn-size: inherit;
  --cf7e-btn-spacing: normal;
  --cf7e-btn-transform: none;
  --cf7e-btn-shadow: none;
  /* input */
  --cf7e-in-line: 1px solid color-mix(in srgb, var(--cf7e-text) 35%, transparent);
  --cf7e-in-top: var(--cf7e-in-line);
  --cf7e-in-right: var(--cf7e-in-line);
  --cf7e-in-bottom: var(--cf7e-in-line);
  --cf7e-in-left: var(--cf7e-in-line);
  --cf7e-in-radius: 4px;
  --cf7e-in-bg: transparent;
  --cf7e-in-pad: .6em .8em;
  --cf7e-in-shadow: none;
  --cf7e-in-text: inherit;
  /* label */
  --cf7e-lb-color: inherit;
  --cf7e-lb-weight: inherit;
  --cf7e-lb-size: inherit;
  --cf7e-lb-spacing: normal;
  --cf7e-lb-transform: none;
  --cf7e-lb-font: inherit;
}
html, body { margin: 0; padding: 0; background: transparent; }
html { overflow: hidden; } /* the iframe grows to fit; it never scrolls inside */
body { padding: 2px 2px 6px; font-family: var(--cf7e-font); font-size: var(--cf7e-size); line-height: var(--cf7e-line); color: var(--cf7e-text); }
body:not(.cf7e-styled) .wpcf7 { visibility: hidden; } /* until the embedding page's look arrives (max 1.5 s) */
.wpcf7 form p { margin: 0 0 1.2em; }
.wpcf7 label {
  display: block; color: var(--cf7e-lb-color); font-family: var(--cf7e-lb-font); font-weight: var(--cf7e-lb-weight);
  font-size: var(--cf7e-lb-size); letter-spacing: var(--cf7e-lb-spacing); text-transform: var(--cf7e-lb-transform);
}
.wpcf7 input:not([type=submit]):not([type=checkbox]):not([type=radio]):not([type=hidden]),
.wpcf7 textarea, .wpcf7 select {
  display: block; box-sizing: border-box; width: 100%; margin-top: .4em; padding: var(--cf7e-in-pad);
  font-family: var(--cf7e-font); font-size: var(--cf7e-size); letter-spacing: normal; text-transform: none;
  color: var(--cf7e-in-text); background: var(--cf7e-in-bg);
  border-top: var(--cf7e-in-top); border-right: var(--cf7e-in-right); border-bottom: var(--cf7e-in-bottom); border-left: var(--cf7e-in-left);
  border-radius: var(--cf7e-in-radius); box-shadow: var(--cf7e-in-shadow);
}
.wpcf7 textarea { min-height: 9em; resize: vertical; }
.wpcf7 :is(input, textarea, select):not([type=submit]):focus { outline: 2px solid transparent; border-color: var(--cf7e-accent); } /* the site's own borders, in the accent color */
.wpcf7 input[type=submit]:focus-visible { outline: 2px solid var(--cf7e-accent); outline-offset: 3px; }
.wpcf7 input[type=submit] {
  font-family: var(--cf7e-btn-font); font-size: var(--cf7e-btn-size); font-weight: var(--cf7e-btn-weight);
  letter-spacing: var(--cf7e-btn-spacing); text-transform: var(--cf7e-btn-transform);
  padding: var(--cf7e-btn-pad); color: var(--cf7e-btn-text);
  background-color: var(--cf7e-btn-bg); background-image: var(--cf7e-btn-image);
  border: var(--cf7e-btn-border); border-radius: var(--cf7e-radius); box-shadow: var(--cf7e-btn-shadow); cursor: pointer;
}
.wpcf7 input[type=submit]:hover { filter: brightness(1.08); }
.wpcf7 .wpcf7-not-valid-tip { color: var(--cf7e-error); font-family: var(--cf7e-font); font-size: calc(var(--cf7e-size) * .875); font-weight: normal; margin-top: .3em; letter-spacing: normal; text-transform: none; } /* tips sit inside the label; do not inherit its small caps style */
.wpcf7 form.invalid .wpcf7-response-output, .wpcf7 form.unaccepted .wpcf7-response-output, .wpcf7 form.failed .wpcf7-response-output { border-color: var(--cf7e-error); }
.wpcf7 form .wpcf7-response-output { margin: 1em 0 0; padding: .6em 1em; border-radius: var(--cf7e-in-radius); }
</style>
</head>
<body class="cf7-embed">
<?php echo do_shortcode( '[contact-form-7 id="' . $form->id() . '"]' ); ?>
<?php wp_footer(); ?>
<script>
(function () {
  var parents = <?php echo wp_json_encode( array_values( $parents ) ); ?>;
  var styled = false;
  function post(msg) { parents.forEach(function (o) { window.parent.postMessage(msg, o); }); }
  function height() {
    window.scrollTo(0, 0); // focus on an invalid field may have scrolled the inside before the iframe grew
    post({ type: 'cf7-embed-height', height: Math.ceil(document.documentElement.getBoundingClientRect().height) });
  }
  function show() { document.body.classList.add('cf7e-styled'); height(); }

  var map = { font: '--cf7e-font', size: '--cf7e-size', line: '--cf7e-line', text: '--cf7e-text', bg: '--cf7e-bg', accent: '--cf7e-accent',
    btnBg: '--cf7e-btn-bg', btnImage: '--cf7e-btn-image', btnText: '--cf7e-btn-text', btnBorder: '--cf7e-btn-border',
    radius: '--cf7e-radius', btnPad: '--cf7e-btn-pad', btnWeight: '--cf7e-btn-weight', btnFont: '--cf7e-btn-font',
    btnSize: '--cf7e-btn-size', btnSpacing: '--cf7e-btn-spacing', btnTransform: '--cf7e-btn-transform', btnShadow: '--cf7e-btn-shadow',
    inTop: '--cf7e-in-top', inRight: '--cf7e-in-right', inBottom: '--cf7e-in-bottom', inLeft: '--cf7e-in-left',
    inRadius: '--cf7e-in-radius', inBg: '--cf7e-in-bg', inPad: '--cf7e-in-pad', inShadow: '--cf7e-in-shadow', inText: '--cf7e-in-text',
    lbColor: '--cf7e-lb-color', lbWeight: '--cf7e-lb-weight', lbSize: '--cf7e-lb-size', lbSpacing: '--cf7e-lb-spacing',
    lbTransform: '--cf7e-lb-transform', lbFont: '--cf7e-lb-font' };
  var FONT_CSS_HOSTS = /^https:\/\/(fonts\.googleapis\.com|use\.typekit\.net|fonts\.bunny\.net)\//;

  /*
   * Contrast guard. Colors read from the page may not work for the form: no button near
   * the iframe (the accent is guessed from a link), a pale brand color, a footer label
   * made for another background. Each color is converted to OKLCH and only its lightness
   * is moved (hue and chroma kept, so it stays the brand color) until it has enough
   * contrast with what it sits on. ?cf7_embed_raw=1 turns this off for comparison.
   */
  var ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
  function rgba(css, under) {
    ctx.clearRect(0, 0, 1, 1);
    ctx.fillStyle = '#000'; ctx.fillStyle = css; // invalid -> stays black
    ctx.fillRect(0, 0, 1, 1);
    var d = ctx.getImageData(0, 0, 1, 1).data, a = d[3] / 255;
    var c = [d[0], d[1], d[2]];
    if (a < 1 && under) c = c.map(function (v, i) { return v * a + under[i] * (1 - a); }); // over the background
    return a === 0 && !under ? null : c;
  }
  function lum(c) {
    return c.map(function (v) { v /= 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); })
      .reduce(function (s, v, i) { return s + v * [0.2126, 0.7152, 0.0722][i]; }, 0);
  }
  function ratio(a, b) { var x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }
  function toOklch(c) {
    var l = c.map(function (v) { v /= 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
    var L = Math.cbrt(0.4122214708 * l[0] + 0.5363325363 * l[1] + 0.0514459929 * l[2]);
    var M = Math.cbrt(0.2119034982 * l[0] + 0.6806995451 * l[1] + 0.1073969566 * l[2]);
    var S = Math.cbrt(0.0883024619 * l[0] + 0.2817188376 * l[1] + 0.6299787005 * l[2]);
    var ok = [0.2104542553 * L + 0.7936177850 * M - 0.0040720468 * S, 1.9779984951 * L - 2.4285922050 * M + 0.4505937099 * S, 0.0259040371 * L + 0.7827717662 * M - 0.8086757660 * S];
    return [ok[0], Math.hypot(ok[1], ok[2]), Math.atan2(ok[2], ok[1])];
  }
  function fromOklch(o) {
    var a = o[1] * Math.cos(o[2]), b = o[1] * Math.sin(o[2]);
    var L = Math.pow(o[0] + 0.3963377774 * a + 0.2158037573 * b, 3), M = Math.pow(o[0] - 0.1055613458 * a - 0.0638541728 * b, 3), S = Math.pow(o[0] - 0.0894841775 * a - 1.2914855480 * b, 3);
    return [4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S, -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S, -0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S]
      .map(function (v) { v = v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; return Math.round(Math.min(1, Math.max(0, v)) * 255); });
  }
  function css(c) { return 'rgb(' + c.map(Math.round).join(' ') + ')'; }
  // fg with its OKLCH lightness moved (the shorter way that works) until ratio(fg, bg) >= min.
  function fix(fg, bg, min) {
    if (ratio(fg, bg) >= min) return fg;
    var o = toOklch(fg), best = null;
    [-1, 1].forEach(function (dir) {
      for (var i = 1; i <= 100; i++) {
        var L = o[0] + dir * i * 0.01;
        if (L < 0 || L > 1) break;
        var c = fromOklch([L, o[1], o[2]]);
        if (ratio(c, bg) >= min) { if (!best || i < best.i) best = { i: i, c: c }; break; }
      }
    });
    return best ? best.c : (ratio([0, 0, 0], bg) > ratio([255, 255, 255], bg) ? [0, 0, 0] : [255, 255, 255]);
  }
  function borderColor(b) { var m = /^\S+\s+\S+\s+(.+)$/.exec(b || ''); return m ? m[1] : null; }
  function borderWidth(b) { return parseFloat(b) || 0; }

  function guard(t) {
    var root = document.documentElement.style, set = function (k, v) { root.setProperty(k, v); };
    var bg = rgba(t.bg || '#fff') || [255, 255, 255];
    var text = fix(rgba(t.text || '#1e1e1e', bg), bg, 4.5);
    set('--cf7e-text', css(text));
    if (t.lbColor) set('--cf7e-lb-color', css(fix(rgba(t.lbColor, bg), bg, 4.5)));
    set('--cf7e-error', css(fix(rgba('#d63638', bg), bg, 4.5)));
    var accent = fix(rgba(t.accent || css(text), bg), bg, 3);
    set('--cf7e-accent', css(accent));

    // Fields: text readable on the field; the field itself must not disappear into the background.
    var inBg = rgba(t.inBg || 'transparent', bg) || bg;
    set('--cf7e-in-text', css(fix(rgba(t.inText || css(text), inBg), inBg, 4.5)));
    var sides = ['inTop', 'inRight', 'inBottom', 'inLeft'];
    var fieldMin = t.strict ? 3 : 1.5; // strict: WCAG 1.4.11 non-text contrast
    var shown = ratio(inBg, bg) >= fieldMin || sides.some(function (k) {
      return t[k] && borderWidth(t[k]) > 0 && ratio(rgba(borderColor(t[k]), bg) || bg, bg) >= fieldMin;
    });
    if (!shown) {
      sides.forEach(function (k) {
        // No field on the page: the default border (text at 35% over the background).
        var v = t[k] || '1px solid ' + css(text.map(function (x, i) { return x * 0.35 + bg[i] * 0.65; }));
        if (!borderWidth(v)) return;
        var c = fix(rgba(borderColor(v), bg) || text, bg, fieldMin);
        set('--cf7e-' + k.replace('in', 'in-').toLowerCase(), v.replace(/^(\S+\s+\S+\s+).+$/, '$1' + css(c)));
      });
    }

    // Button: it must stand out from the background (fill or border), and its label must be readable.
    var gradient = t.btnImage && t.btnImage !== 'none';
    var fill = t.btnBg === undefined ? accent : rgba(t.btnBg, null);
    var border = t.btnBorder && borderWidth(t.btnBorder) > 0 ? rgba(borderColor(t.btnBorder), bg) : null;
    if (!gradient) {
      if (fill && !(ratio(fill, bg) >= 3 || (border && ratio(border, bg) >= 3))) {
        fill = fix(fill, bg, 3);
      } else if (!fill && border && ratio(border, bg) < 3) {
        border = fix(border, bg, 3);
        set('--cf7e-btn-border', t.btnBorder.replace(/^(\S+\s+\S+\s+).+$/, '$1' + css(border)));
      }
      if (fill) set('--cf7e-btn-bg', css(fill));
      var under = fill || bg;
      var label = rgba(t.btnText || '#fff', under);
      if (!t.btnText) label = ratio([255, 255, 255], under) >= ratio([20, 20, 20], under) ? [255, 255, 255] : [20, 20, 20];
      // A pair taken from the site's own button is its design: hold it only to 3:1 (bold button text).
      // A guessed color gets the full 4.5:1.
      set('--cf7e-btn-text', css(fix(label, under, t.btnBg === undefined ? 4.5 : 3)));
    }
  }

  window.addEventListener('message', function (e) {
    if (parents.indexOf(e.origin) < 0 || !e.data || e.data.type !== 'cf7-embed-style') return;
    styled = true;
    var t = e.data.tokens || {};
    Object.keys(map).forEach(function (k) {
      // Values are CSS values read from the parent's computed styles; reject anything that could break out.
      if (typeof t[k] === 'string' && t[k] && t[k].length < 300 && !/[;{}<>]|url\(/i.test(t[k])) {
        document.documentElement.style.setProperty(map[k], t[k]);
      }
    });
    if (!/[?&]cf7_embed_raw=1/.test(location.search)) guard(t);
    // Web font style sheets (Google Fonts etc.) are loaded here; their fonts are served with CORS.
    var cssLoads = (e.data.fontLinks || []).filter(function (h) { return typeof h === 'string' && FONT_CSS_HOSTS.test(h); }).slice(0, 4).map(function (h) {
      return new Promise(function (done) {
        var l = document.createElement('link');
        l.rel = 'stylesheet'; l.href = h; l.onload = l.onerror = done;
        document.head.appendChild(l);
      }).then(function () { return document.fonts.ready; });
    });
    var loads = cssLoads.concat((e.data.fonts || []).slice(0, 8).map(function (f) {
      try {
        var face = new FontFace(f.family, f.data, { weight: String(f.weight), style: String(f.style) });
        document.fonts.add(face);
        return face.load().catch(function () {});
      } catch (err) { return Promise.resolve(); }
    }));
    Promise.race([Promise.all(loads), new Promise(function (r) { setTimeout(r, 2500); })]).then(show);
  });

  // Ask for the look until the parent script answers (it may load after this page).
  var tries = 0;
  (function ready() {
    if (styled) return;
    post({ type: 'cf7-embed-ready' });
    if (++tries < 15) setTimeout(ready, 100 * tries);
  })();
  setTimeout(function () { if (!styled) show(); }, 1500); // no parent script: show with defaults
  new ResizeObserver(height).observe(document.documentElement);
  // Focusing a field can scroll even an overflow:hidden page; the iframe shows everything, so undo it.
  window.addEventListener('scroll', function () { if (window.scrollY) window.scrollTo(0, 0); });
  window.addEventListener('load', height);
})();
</script>
</body>
</html>
    <?php
    exit;
  }
);

4 つの方法を比べる

確認を外して PHP 8.1 で動かすNode で WASMブラウザーで WASM外部サーバー+iframe
ショップ側に必要なものプラグインプラグイン、Node、proc_openプラグインHTML を貼れること
サーバーの追加なしなしなしもう 1 台(PHP 8.3)
ショップの構成・制限の影響受ける(PHP 8.1 で動き続けるか)受ける(Node や proc_open が使えるか)少し受ける(プラグインを入れられるか)受けない
CF7 の機能すべて添付以外表示・入力チェック・通知メールだけすべて
速さ速い送信 約 1 秒初回 12.5 MB送信 約 0.33 秒
今後の更新壊れるおそれ手作業手作業いつもどおり
おすすめ×△×(実験向け)◎

WASM で動かすと安全になるところ

今回は「古い PHP の延命」のために WASM を使いましたが、試してみると、別の価値も見えてきました。WASM の PHP から見えるのは、渡したフォルダーだけです。

  • プラグインに穴があっても、本体の wp-config.php(DB のパスワードなど)や別のサイトには届かない
  • 専用の SQLite で動くので、SQL インジェクションがあっても本体の DB は無事
  • メールの送信は本体が握るので、宛先や量を本体側で制限できる

一方で、作った HTML は本体と同じドメインのページに出るので、XSS は防げません。本体の PHP が古いことの危険も残ります。WASM は延命の手段より、信用しきれないプラグインを隔離する手段として考えるほうが筋がよさそうです。

気をつけること

  • iframe の中は外部サーバーのドメインです。ドメインを自分で管理できるなら、forms.ショップのドメイン のようなサブドメインにすると見た目もそろいます
  • メールは外部サーバーから送られます。送信元をショップのドメインにするなら、SPF や DKIM で外部サーバーを許可するか、ショップのドメインの SMTP で送る設定にしてください
  • デザインの自動調整は、ページの本文・ボタン・入力欄の見た目を読み取るものです。ホバーやフォーカスの演出、後から切り替わるダークモードまでは追いかけません。細かく合わせたいときは、手本の部品を指定するか、外部サーバー側で CSS を足してください
  • フォームの文言(「Your name」など)は CF7 側の設定です。外部サーバーの WordPress でフォームを日本語にしておきましょう。見た目で一番目立つ差になります
  • 外部サーバーが止まると、フォームも止まります
  • WASM の二つの方法は試作です。本番で使うなら、隔離の手当て(読み取り専用、ネットワークの遮断)や監視がさらに必要です

まとめ

  • Contact Form 7 は 6.2 から PHP 8.3 以上が必要。EC サービス付属の WordPress などでは、PHP を上げられないことがある
  • WASM の PHP 8.3 で CF7 だけを動かす方法は、サーバーの Node でも、訪問者のブラウザーでも動いた。ただし仕組みが複雑で、使えない機能もある
  • おすすめは、PHP 8.3 が動く外部サーバーで CF7 を普通に動かし、iframe で埋め込む方法。サーバーはもう 1 台要るが、ショップの構成やサーバーの制限に左右されない。ショップ側は 2 行貼るだけで、デザインも自動でなじみ、CF7 の機能もすべて使える
  • 5 種類のショップのデザインで試し、近くの部品を手本にすることと、OKLCH で明るさだけを直すことで、自動でもかなりなじむ
  • WASM は、古い PHP の延命より、プラグインの隔離に向いていそう