WordPressの記事に埋め込んだJavaScriptで、&&が表示のときだけ書き換わって動かない。引き金は手前の不等号だった

運営者

手元では動くJavaScriptが、WordPressの公開ページでだけ止まりました。
JavaScriptは、ページの中で動くプログラムのことです。
ボタンを押しても、結果の欄は空のままでした。

公開ページの中身を見ると、&& が && に変わっています。
&& は「かつ」を表す記号です。
& は & を番号で書き表した形で、JavaScriptとしては読めません。

引き金は、その手前にあった比較の < でした。
WordPressは表示のときに、< から次の > までをタグとみなします。
その間にある & は、script の中でも &#038; に置き換えられます。
比較をすべて > の向きに書き直すと、書き換えは0か所になりました。

7月22日の朝、8本のうち3本が止まっていた

置いていたのは、別のサイトの記事に埋め込んだ無料の診断ツールです。
2026年7月21日の夜から22日の朝にかけて、部下役のAIが新しく7本を公開しました。
最後の1本を公開した直後に試すと、入力して診断のボタンを押しても、何も出てきません。

ブラウザの中でJavaScriptを読み込ませると、構文エラーが返ってきました。
構文エラーは、書き方の決まりに合わず、読み込みの段階で止まることです。

SyntaxError: Invalid or unexpected token

手元のファイルと公開ページを1行ずつ比べました。
&& が &#038;&#038; に置き換わった行が、3行あります。
その時点で公開していた8本を調べると、同じ書き換えは3本にありました。
&#038; の数は、2つ、2つ、12です。
残りの5本は0でした。

壊れていたのは、こういう行です。
変数の名前は置き換えましたが、記号の並びは元のままにしています。

// 手元のファイル
const over  = d.max!=null && x>d.max && x-d.max<=2;
const under = d.min!=null && x<d.min && d.min-x<=2;

// 公開ページ
const over  = d.max!=null && x>d.max && x-d.max<=2;
const under = d.min!=null &#038;&#038; x<d.min &#038;&#038; d.min-x<=2;

この下に続く2行でも、同じように && が書き換わっていました。

書き換えていたのは、表示のときの wptexturize

正体は、WordPressの wptexturize という処理でした。
記事を表示するたびに、引用符やダッシュなどの記号を、見た目の整った文字に置き換える処理です。
WordPressのコードでは、記事の本文を表示するときの処理の一覧に入っています。

公式のコードリファレンスには、一部のHTMLの中のコードは飛ばす、とあります。
飛ばす対象は pre code kbd style script tt の6つです。
script の中は、本来は手を付けない場所のはずでした。

ところが、< で始まる部分だけは扱いが別です。
wptexturize は本文を、タグらしい部分とそれ以外に切り分けてから処理します。
タグらしい部分は、< から次の > まで、と正規表現で決まっていました。
正規表現は、文字の並びを探すための書き方です。

// formatting.php(抜粋)
'<'          // Find start of element.
'[^>]*>?'    // Find end of element. If not found, match all input.

// タグらしい部分では、& を &#038; に置き換える
$curl = preg_replace( '/&(?!#(?:\d+|x[a-f0-9]+);|[a-z1-4]{1,8};)/i', '&#038;', $curl );

タグらしい部分の & は、script の中かどうかに関係なく置き換えられます。
さっきの行なら、x-d.max<=2; の < から、次に > が出てくるまでが1つのタグに見えます。
その間の && が、&#038;&#038; になっていたのです。
> が最後まで出てこなければ、本文の終わりまでがタグの扱いになります。

データベースに保存された本文は無傷でした。
表示のたびに書き換わるので、保存し直しても直りません。

1回目は、不等号と英字の組み合わせしか直していなかった

最初に部下役のAIが疑ったのは、x<d.min のように < のすぐ後ろに英字が来る形でした。
HTMLのタグも、<div のように < と英字で始まるからです。
この形を d.min>x と逆向きに書き直し、&& の前後に空白も入れました。

公開ページで数え直すと、&#038; は2つ、2つ、8つ残っています。
残っていたのは、<=2 や <3 のように、< のあとに数字や = が続く比較でした。
さっきのコードのとおり、WordPressは < の次の文字を見ていません。

2回目に、script の中の比較の < を、すべて > の向きに直しました。
3本とも &#038; は0になりました。
公開ページで実際に入力して、結果が出ることも確かめています。

8月8日の夜には、同じサイトに置いた管理者用のツールが、同じ形で止まりました。
出す前の点検で、前後が空白の < しか数えておらず、g.position <= 20 を見落としていたのです。
公開ページのこの1行は、手元の73字より10字長い83字でした。
1字の & が6字の &#038; になり、それが2つで10字ふえた計算です。
20 >= g.position と向きを変えると、長さは手元と同じ12,056字に戻りました。

10月には、タグを組み立てる文字列の途中で起きた

10月13日には、比較ではなく、HTMLを組み立てる文字列で起きました。
ボタンの部品を文字列でつなぎ、その途中に && を書いていた行です。
名前を置き換え、関係のない部分を省いて載せます。

return '<button class="' + cls + '"' + ((learn && done) ? ' disabled' : '') + '>';

'<button の < から、最後の >' までが1つのタグに見えます。
その間の && が書き換わり、構文エラーになりました。
このときも、保存されていた本文は無傷で、壊れていたのは表示だけです。

'<div class="x">' のようなタグの文字列そのものは、何度も通っています。
間に & が無ければ、置き換える文字が無いからです。

比較は > に向け、タグの文字列の中に & を置かない

直し方は2つでした。
1つ目は、比較の < を > の向きに書き直すことです。
意味は変わりません。

// 前
for (var i = 0; i < list.length; i++) { }
if (a <= 20 && b >= 3) { }

// 後
for (var i = 0; list.length > i; i++) { }
if (20 >= a && b >= 3) { }

2つ目は、タグを組み立てる文字列の途中で && を使わないことです。
条件は先に変数へ出してから、文字列につなぎます。

var dis = (learn && done) ? ' disabled' : '';
return '<button class="' + cls + '"' + dis + '>';

もっと固くするなら、script の中に & を1つも書かない、という決め方もあります。

JavaScriptを本文の外のファイルにして読み込めば、本文にかかるこの処理は通らないはずです。
ただ、僕の置き場所はメディアに .txt や .json すら上げられず、データまで本文に埋め込んでいます。

出す前に、WordPressと同じ区切り方で確かめる

目で探すと、また見落とします。
そこで部下役のAIに、WordPressの区切り方をそのまま写した検査を書いてもらいました。
ツールのHTMLファイルを渡すと、表示のときに & が書き換わる行を出します。
目次プラグインが割り込む見出しタグも、あわせて探します。

# 使い方: python wp_script_check.py tool.html
import re
import sys

text = open(sys.argv[1], encoding="utf-8").read().replace("\r\n", "\n")
# WordPress の wptexturize と同じ区切り方。< から次の > までをタグとみなす
TAG = re.compile(r"(<(?:(?=!--)!(?:-(?!->)[^-]*)*(?:-->)?|(?!!--)[^>]*>?))")
AMP = re.compile(r"&(?!#(?:\d+|x[a-f0-9]+);|[a-z1-4]{1,8};)", re.I)

bad = 0
for js in re.findall(r"<script[^>]*>(.*?)</script>", text, re.S):
    parts = TAG.split(js)
    after = "".join(AMP.sub("&#038;", p) if p.startswith("<") and not p.startswith("<!--") else p
                    for p in parts)
    for a, b in zip(js.split("\n"), after.split("\n")):
        if a != b:
            print("& が書き換わる行: " + a.strip())
            bad += 1
    for m in re.finditer(r"<h[2-4]\b", js):
        print("JSの中の見出しタグ: " + js[m.start():m.start() + 40])
        bad += 1
print("問題 %d か所" % bad)
sys.exit(1 if bad else 0)

11月11日、記録に残っていた行に、この検査と同じ区切り方を当ててみました。
7月22日の3行は、公開ページに残っていた形と1文字も違わずに書き換わりました。
&#038; は12個で、記録の数と同じです。
8月8日の行と10月13日の行も、公開ページと同じく10字ふえました。
書き換わった行を手元のNode.js(パソコンでJavaScriptを動かす道具)に読ませると、同じ構文エラーが出ます。

これはWordPress本体を動かした確かめ方ではありません。
テーマやほかのプラグインが表示に足す処理も、ここでは見ていません。
WordPressに入れたあとは、公開ページで動かして確かめます。

公開ページに出た形で、原因をしぼる

埋め込んだJavaScriptでは、ほかの止まり方にも当たっています。
公開ページの形を見ると、原因の見当がつきます。

  • && が &#038;&#038; になっている:手前の < と、この記事の話です
  • script の中に <p> がある:<!-- wp:html --> の包み忘れです
  • 見出しタグに id="rtoc-…" がふえている:目次プラグインの書き換えです
  • 保存そのものが403で止まる:サーバーのWAFが止めていました

<p> のほうは、8月11日にデータを差し替えたときに起きました。
コンソールに出たのは SyntaxError: Unexpected token '<' です。
script の中には、<p> と </p> が31個ずつ入っていました。
本文を、エディタの「カスタムHTML」ブロックの印である <!-- wp:html --> で包まずに保存していたのです。
<p> を入れたのは、空行を段落の <p> に変える wpautop という処理でした。
WordPressのコードには、ブロックのある本文では、あとで wpautop を通さない、という注記があります。
包み直して保存すると、<p> は0になり、構文エラーも消えました。

参考

保存の段階で止まるときは、原因が別のところにあります。
WordPressの保存が「閲覧できません (Forbidden access)」403で止まる。原因はサーバーのWAFだった

記事URLをコピーしました