🏷️

<script>要素の構文

2021/08/22に公開

<script> タグ内部は利便性や歴史的な経緯から、SGML/HTML/XMLの一般的なテキストノードとは異なる構文を持ちます。

本稿は最新のHTML仕様に基づいて <script> 要素内の構文を説明します。執筆時点のバージョンはここから参照できます

HTMLとXHTML

(XHTMLに興味がない人は読み飛ばしてOKです)

構文的な観点からは、HTMLとXHTMLは「よく似た別のマークアップ言語」であると考える必要があります。 <script> の扱いはそのような顕著な例のひとつです。XHTMLにおけるベストプラクティスとHTMLにおけるベストプラクティスは排他的であり、うまく共存する手は基本的にありません。

XHTMLでは <script> というタグ名が構文的に例外扱いされることはなく、かわりにCDATA sectionを使ってエスケープを回避するのが一般的です。

<script><![CDATA[
if (42 < 80) {
  document.body.appendChild(document.createElement("div"));
}
]]></script>

最新のHTML仕様 (わかりやすく言えばHTML5) ではこの方法は使えません。特別な括弧は置かずに、そのままプログラムを記述します。

<script>
if (42 < 80) {
  document.body.appendChild(document.createElement("div"));
}
</script>

script data 状態群

HTML StandardではHTMLの字句解析器を文字単位のオートマトンとして記述しています。このうち、 <script> 直後の状態が "script data" です。関連する状態として以下があります。

状態 条件
script data なし
script data less-than sign 直前が <
script data end tag open 直前が </
script data end tag name 直前が </[a-z]+ (case-insensitive)
script data escape start 直前が <!
script data escape start dash 直前が <!-

他の状態グループへの遷移条件は以下の通りです。

  • </script[>/\t\f\n ] に遭遇したとき (case insensitive)
    • > の場合はdataに遷移する。 (通常状態に戻る)
    • / の場合は </script/ をパースし切るためにself-closing start tagに遷移する。
    • [\t\f\n ] の場合は </script をパースし切るためにbefore attribute nameに遷移する。
    • いずれの場合も <script> からの脱出が確定する。また、 </script 部分はDOMのテキストノードには含まれない。
  • <!-- に遭遇したとき
    • script data escaped dash dash に遷移する。
    • 関連するテキストは全てDOMに含まれる。

script data escaped 状態群

<script> に続けて、 <!-- が入力された状態です。関連する状態として以下があります。

状態 条件
script data escaped なし
script data escaped dash 直前が -
script data escaped dash dash 直前が --
script data escaped less-than sign 直前が <
script data escaped end-tag open 直前が </
script data escaped end-tag name 直前が </[a-z]+ (case-insensitive)
script data double escape start 直前が <[a-z]+ (case-insensitive)

他の状態グループへの遷移条件は以下の通りです。

  • </script[>/\t\f\n ] に遭遇したとき (case insensitive)
    • > の場合はdataに遷移する。 (通常状態に戻る)
    • / の場合は </script/ をパースし切るためにself-closing start tagに遷移する。
    • [\t\f\n ] の場合は </script をパースし切るためにbefore attribute nameに遷移する。
    • いずれの場合も <script> からの脱出が確定する。また、 </script 部分はDOMのテキストノードには含まれない。
  • <script[>/\t\f\n ] に遭遇したとき (case insensitive)
    • script data double escaped に遷移する。
    • 関連するテキストは全てDOMに含まれる。
  • --> に遭遇したとき
    • script dataに遷移する。
    • 関連するテキストは全てDOMに含まれる。
    • この --> は直前に消費した <!-- と重なっていてもよい。
      • ただしこれはパーサー側の視点で、 §4.12.1.3 にある生成側の規定では重なってはいけないことになっている。

script data double escaped 状態群

<script>, <!-- 加えてさらにもう1度 <script> が入力された状態です。関連する状態として以下があります。

状態 条件
script data double escaped なし
script data double escaped dash 直前が -
script data double escaped dash dash 直前が --
script data double escaped less-than sign 直前が <
script data double escape end 直前が </

他の状態グループへの遷移条件は以下の通りです。

  • </script[>/\t\f\n ] に遭遇したとき (case insensitive)
    • script data escaped に遷移する。
    • 関連するテキストは全てDOMに含まれる。
  • --> に遭遇したとき
    • script data に遷移する。

オートマトン

以上のように状態をまとめたオートマトンが以下です。

図: script要素内の字句解析オートマトン

以下は現在のHTML仕様に即した実装がどのように振る舞うかの説明です。

</script が来ない限りスクリプトは終わりません。中のテキスト中にエスケープがあっても展開されずそのまま実行されます。

<script>
alert("&amp;"); // => ampがそのまま出る
alert("<a>foo"); // => <a> がそのまま出る
</Script>

</script> があればその時点で終わりです。

<sCRIpt>
alert("&amp;"); // => ampがそのまま出る
alert("</scrIpt>"); // => 2個目の " の直前でscriptが終わってしまう。残りは表示可能テキストとしてそのまま表示される。
</script>

HTMLコメントもHTMLコメントとしてではなく生テキストとして解釈されます。

<script><!--
if (0 < 1) alert("OK!");
//--></SCRIPT>

先頭の <!-- はJavaScript処理系に渡されますが、ブラウザのJavaScript処理系は通常ECMA-262のAnnex Bを追加実装しているのでSingleLineHTMLOpenCommentとみなされ構文エラーにはなりません。

最近の仕様では末尾の --> も同様にコメントと見なされるため、 (// でコメントアウトしなくても) 問題ありません。

この方法は <script> タグを認識できないブラウザが存在していた太古の時代のベストプラクティスとして存在していたものです。

また、上のように <!-- ... --> で囲まれている場合に限り、 <script> ... </script> の対が1段だけ認識されます。 (--> は生成時の要件としては必要だが、省略してもパースされる)

<script><!--
document.write('<script src="tracker.js"></script>'); // これはちゃんと実行される
//--></script>

この特別なルールは <!-- --> の中でのみ有効なため、現代的な書き方ではうまくいきません。

<script>
document.write('<script src="tracker.js"></script>'); // 途中でscriptが閉じてしまうため失敗
</script>

どのようにエスケープするか

通常のテキストと異なり、 <script> 要素内のテキストには以下のような特徴があります。

  • 要素が終わるときは必ず </script で終わる。 (ただし、逆は必ずしも真ではない)
  • エスケープの展開は起こらず、要素の始めから終わりまでがそのままテキストノードになる。
    • ただし、パース前の前処理として /\r|\r\n/ をLFに置き換える処理だけは発生する。

つまり、DOMにこのようなテキストがあった場合、それをHTMLとしてシリアライズすることは不可能です。このことはHTML仕様の§4.12.1.3にも書かれています。

The script element's descendant text content must match the script production in the following ABNF, the character set for which is Unicode. [ABNF]

同節を素直に読むと <!-- ... <script> を含んではいけないという風に読め、 </script> については禁止されていません。ただこの部分の意図を考えると、 </script> については単なる記載漏れという可能性が高そうです。

さて、同節のNoteに、どうしてもHTMLを生成したい場合のアドバイザリがあります。

The easiest and safest way to avoid the rather strange restrictions described in this section is to always escape an ASCII case-insensitive match for "<!--" as "<\!--", "<script" as "<\script", and "</script" as "<\/script" when these sequences appear in literals in scripts (e.g. in strings, regular expressions, or comments), and to avoid writing code that uses such constructs in expressions. Doing so avoids the pitfalls that the restrictions in this section are prone to triggering: namely, that, for historical reasons, parsing of script blocks in HTML is a strange and exotic practice that acts unintuitively in the face of these sequences.

つまり、JavaScriptのレイヤでソースコードにエスケープを加えることが推奨されています。これは通常 <!--, <script, </script が文字列・正規表現・コメントの一部としてしか出てこないという仮定のもとでのアドバイスだと考えられます。そうではない場合も <script> のオーバーランやアンダーランは防げますが、コーナーケースでは中のスクリプトが壊れる可能性があります。

<script><\!-- // Annex Bのコメントの条件を満たさなくなってしまう
// ...
//--></script>
<script>
// \ が入ったことでJavaScriptコードとして正しくなくなってしまう
const isEverythingFine = 42<\/script tag/.toString().length;
</script>

もし厳密なエスケープが必要なら、JavaScriptをきちんと構文解析する以外にいい方法はなさそうです。

まとめ

  • HTML (XHTMLではない) の <script> は最初に </script を見つけるまでのテキストをunescapeせずにそのまま使う。
    • ただし、 </script は空白、 >, / のいずれかで後続されていなければならない。
    • また特別ルールとして、 <script> 内に <!-- ... --> があり、その中にさらに <script ... </script がある場合、この対をなす </script はJavaScriptの一部とみなされる。
  • これらの危険な文字列を、DOMからHTMLを出力するときにエスケープする手段はないため、DOMに入れるタイミングでエスケープする必要がある。
  • 全てのJavaScriptソースコードに対して一様に正しくエスケープするのは難しいため、コーナーケースを諦めるか、JavaScriptをパースして適切なエスケープ手段を選択する必要があると考えられる。

Discussion