🙄

CSS大解剖 8日目: 「構文 1/2」

に公開

本稿は、2024年2月頃に書き溜めていたシリーズです。最後まで温存させるのが勿体ないので、未完成ですがそのまま公開します(公開日: 2025/9/23)。そのため、内容の重複や記述方針の不一致があるかもしれませんが、ご理解ください。


CSSの仕様を理解するために、1日ごとにテーマを決めて説明する企画8日目です。今日のテーマは「構文」です。

おことわり

毎度のことですが、本シリーズにはCSS実装者向けの細かい仕様への言及が含まれます。これらの記述は、コーナーケースを利用したテクニカルなスタイルを書くことを必ずしも推奨するものではありません。仕組みを理解した上で、それを使うべきかどうかはあなた自身が判断してください。

構文と前方互換性

CSS構文の最も重要な特徴は、それが前方互換性を考慮して規定されていることでしょう。

たとえば以下のCSSを考えます。

@font-face {
  font-family: MyFont;
  src: url(https://example.com/fonts/MyFont.woff2);
}

p {
  font-family: MyFont, serif;
  color: #111;
}

もし、このスタイルを古いWebブラウザで解釈したらどうなるでしょうか? 古いWebブラウザ@font-face at-ruleを解釈できないかもしれません。[1]

もし @font-face at-ruleを解釈できない場合、このCSSは構文エラーになります。その場合でも、なるべくそのWebブラウザが解釈できる範囲内で多くのスタイルを適用できることが望ましいです。そこで、CSSではパースエラー発生時のリカバリ方法が明示的に規定されており、パースエラーの周辺だけを破棄して残りを適用するようになっています。

上の例の場合、 @font-face at-ruleは破棄されますが、構文解析は継続され、その下の p に対するルールはパースされます。つまり、構文解析の結果は以下のようになります。

p {
  font-family: MyFont, serif;
  color: #111;
}

さらに、 font-familyには複数のフォント名が指定されているため、この場合は serif フォントと少し明るい黒色で文字を表示するという指定として解釈されます。

このように、CSS構文定義における「パースエラー」とは、単にそのようなログが記録されるという程度の意味しか持ちません。CSSの振る舞いを理解するには、パースエラーという事実だけではなく、パースエラー発生後に何が起きるかに注目する必要があります。 (もちろん、CSSを書く側の立場からは、パースエラーがそもそも起きないように書くことが重要です)

以降の説明は、このことを意識しながら読んでみるとよいでしょう。

前処理

CSSのパース前に、文字のデコードと改行の正規化が行われます。

文字のデコード

文字エンコーディングは外部環境から決定されますが、もし決まらない場合は @charset 擬似at-ruleを参照します。これは以下のような構文です。

@charset "utf-8";

しかし、この構文は非常に単純なマッチングにより検査されるため、以下のように強い制限があります。

  • ASCII互換エンコーディングの場合のみ有効です。
  • CSSの冒頭に記載される必要があります。
  • @ の前を含め、余計な空白文字・改行文字やコメントを置くことはできません。
  • charset は小文字、その直後の空白はASCIIスペース文字、引用符はASCIIダブルクオートである必要があります。

また、文字エンコーディング指定が utf-16be または utf-16le と完全一致する場合は誤りとみなして utf-8 に再解釈されます。

CSS3においては、この擬似at-ruleはパーサーの特別処理としてのみ定義されており、メインのパーサー内では無効なat-ruleとして無視されます。

改行の正規化

以下の文字(列)は U+000A LINE FEED (LF) に置き換えられます。

  • U+000D CARRIAGE RETURN (CR)
  • U+000D CARRIAGE RETURN (CR) + U+000A LINE FEED (LF)
  • U+000C FORM FEED (FF)

FFを改行とみなす点がHTMLと異なります。

トークン化

多くのプログラミング言語と同様、CSSの構文も字句文法構文文法の2段構成になっています。1段階目にあたる字句解析では、入力ストリームをトークンと呼ばれる単位の列に分解します。また、CSSの字句解析も他の一般的な字句解析と同じく、最長一致するトークンを採用することを原則としています。

CSSにおいて、このステップの主眼は括弧対を特定できるようにすることにあります。この目的から、いくつかのトークンは必然的にこの段階で解析する必要があることになります。

  • 括弧対を特定できるようにするには、 (, ), [, ], {, } の6種類のトークンを特定できる必要があります。
  • これらの文字がトークンにならない条件がいくつかあるため、これらは同時に処理する必要があります。
    • コメント内の場合。 /* { */ など
    • 文字列内の場合。 "{" など
  • さらに、文字列の開始位置を検知する上で、コメント以外に障害になるものがもう1つあります。
    • url() 内の場合。 url(https://example.com/") など
  • さらに、 url() を特定するために、以下のような可能性を排除しておく必要があります。
    • url (
    • @url(
    • #url(

また、エラーリカバリーのために、正しく解析できなかった字句をあらわす <bad-string-token><bad-url-token> も存在します。

トークン一覧

引数
<ident-token> foo 名前
<function-token> foo( 名前
<at-keyword-token> @foo 名前
<hash-token> #foo 名前、種別
<string-token> "foo" 文字列
<bad-string-token> "foo -
<url-token> url(foo) 文字列
<bad-url-token> url(foo -
<number-token> 123 数値、種別
<percentage-token> 123% 数値、種別
<dimension-token> 123px 数値、種別、単位名
<whitespace-token> -
<CDO-token> <!-- -
<CDC-token> --> -
<colon-token> : -
<semicolon-token> ; -
<comma-token> , -
<[-token> [ -
<]-token> ] -
<(-token> ( -
<)-token> ) -
<{-token> { -
<}-token> } -
<delim-token> & 記号

コメント

コメントは /* ... */ です。ネストは認識されません。

CSSには // による1行コメントは存在しません。Sassなどの拡張言語の中には1行コメントをサポートしているものもありますが、CSSでは //<delim-token> <delim-token> として解析するよう規定されているため勝手に1行コメントを実装することはできません。[2]

空白

空白には改行のほか、スペース文字とタブ文字が含まれます。

CSSでは空白が独立したトークンとして認識されます。これは主に、セレクタの子孫結合子を単純セレクタの並びと区別するために使われます。

/* <p class="nav"></p> */
p.nav {}
/* <p><span class="nav"></span></p> */
p .nav {}

コメントは空白トークンを生成しません。もちろん、コメントの前後に空白文字がある場合はその限りではありません。

/* <p class="nav"></p> */
p/**/.nav {}

空白文字がいくつ並んでいても1つの空白トークンになりますが、コメントで区切られている場合は連続した空白トークンが生成されます。これはほとんど定義上の問題にすぎず、実用上は気にしなくても問題ありません。

空白を明示的に扱う必要がある構文は限られるため、明示的に指定されていない限りは空白トークンは無視されます。

エスケープ

以下のトークン内ではエスケープが利用できます。

  • 識別子系のトークン
    • <ident-token>
    • <function-token>
    • <at-keyword-token>
    • <hash-token>
  • 文字列系のトークン
    • <string-token>
    • <bad-string-token>
  • URL系のトークン
    • <url-token>
    • <bad-url-token>

エスケープの構文はJavaScriptのそれとはやや異なるため注意が必要です。

エスケープは \ で始まります。これには以下の4パターンがあります

  • 16進桁が続く場合…… 16進コードによるエスケープ \1F914
  • 16進桁でもEOFでも改行でもない文字が続く場合…… リテラルエスケープ \\
  • EOFが続く場合…… 不正なエスケープ
  • 改行文字が続く場合…… この場合はエスケープではなく、行結合とみなされる。文字列中以外ではエラー。

16進コードによるエスケープは、最大6桁まで貪欲に読み、さらに後続する空白を最大1文字まで読みます。言い換えると、以下の4パターンがあります。

  • 6桁かつ空白が続く場合 \01F914 ...
  • 6桁かつ空白が続かない場合 \01F914...
  • 5桁以下かつ空白が続く場合 \1F914 ...
  • 5桁以下かつ空白も16進桁も続かない場合 \1F914...

空白を読むようになっているのは、エスケープの区切りを明示できるようにするためと考えられます。以下の場合は空白が必要になります。

  • 5桁以下で、次の文字が16進桁と混同されうる場合。ただし、桁を0埋めして6桁に伸長するか、次の文字もエスケープすることで対応できます。
    • \1F914 foo
    • \01F914foo
    • \01F914\66oo
  • 真の空白文字を後続させたい場合。ただし、次の文字もエスケープするという方法でも対応できます。
    • \1F914
    • \1F914\20
    • いっぽう、 \01F914 のように伸長しても次の空白はエスケープの一部になってしまいます。

識別子

JavaScriptなどの主要なプログラミング言語と異なり、CSSにおける識別子名にはほとんど制限がありません。唯一存在する条件は、識別子名が空ではないことだけです。CSSが識別子のエスケープを許容しており、エスケープの解釈後に識別子のフォーマットを検査する規則がないため、このように非常に緩い結果になっています。

当然、識別子中に使える、エスケープされていない文字には制限があります。これを踏まえたルールは以下の通りです。

  • 識別子を開始できるのは以下の文字(列)だけです。
    • 最初の文字が ident-start code pointであること。ident start code pointとは、以下のいずれかです。
      • ASCII 英字 (AZ および az)
      • ASCII アンダースコア (_)
      • 非ASCII文字 (Unicodeコードポイント U+0080 以上の文字)
    • 最初の文字がエスケープされていること。
    • - + ident-start code point で開始していること。
    • - + エスケープ で開始していること。
    • -- で開始していること。
  • 識別子の途中に出現することができるのは以下の文字(列)だけです。
    • ident code point。つまり、ident-start code point またはASCII数字 (09) または -
    • エスケープ。

たとえば、 123-123 は識別子ではありませんが、 \31\32\33-\31\32\33 は識別子です。

識別子の派生トークン

識別子からの直接の派生トークンが3種類あります。

  • <function-token>foo( のように関数記法の開始をあらわすトークンです。
  • <at-keyword-token>@foo のようにat-ruleの開始をあらわすトークンです。
  • "id" type flag を持つ <hash-token>#foo のように、16進カラーコードやIDセレクタとして使われるトークンです。

加えて、 <hash-token> には以下の一般化が存在します。

  • 123-123 のように、通常は識別子とみなされない字句であっても、 # の直後であれば <hash-token> とみなされます。基準は以下の通りです。
    • 1文字以上の文字からなること。
    • 全ての文字が、識別子の途中に出現できる (ident code point またはエスケープである) こと。
  • このルールで認められる <hash-token> は "unrestricted" type flagを持つ <hash-token> と呼ばれます。

これにより #123#-123<hash-token> として認められます。この一般化は、16進カラーコードをサポートする上で重要です。

文字列

文字列" または ' で開始され、同じ文字で閉じられます。

  • \ はエスケープまたは行結合の開始を意味します。
    • エスケープの解釈は前述の通りです。
    • \ の直後が改行文字の場合、これは行結合です。この2文字は単に読み飛ばされます。続くインデントは読み飛ばされないので注意が必要です。
  • EOFに遭遇した場合はパースエラーです。
  • 行結合ではない改行文字に遭遇した場合もパースエラーです。

興味深いことに、パースエラーになった場合の挙動はEOFと改行で異なります。たとえば以下のCSSを考えます。 (末尾改行は無いものとします)

#main::before {
  /* 有効 */
  content: "Hello,

この場合、 "Hello, は完全な文字列ではないためパースエラーが記録される一方、トークンとしては <string-token> として取り扱われます。そのため上記の content プロパティは有効と判定されます。

いっぽう、以下のような場合は <bad-string-token> が返ってくるため、スタイル指定は無効です。

#main::before {
  /* 無効 */
  content: "Hello,
}

url() トークン

url() は字句解析において特別な扱いを受けます。これは以下の条件が全て満たされたときに発生します

  • <function-token> 相当の字句が読み込まれたタイミングであること。
  • かつ、 <function-token> の名前が大文字小文字を無視して url にマッチすること。 (たとえば url(, URL(, u\72L( などがこれに該当する。)
  • 加えて、仮にこの位置から空白文字を読み飛ばしたときに、次に来る文字が " でも ' でもないこと。 (たとえば url("https://example.com") はこれに該当しない。)

これらが満たされた場合は url(<function-token> として解釈するのをやめ、 <url-token> の開始として再解釈します

<url-token> の終了は、以下のいずれかによって判定されます。

  • エスケープされた閉じ丸括弧 (\)) 以外の閉じ丸括弧 )
  • EOF。この場合はパースエラー。

<url-token>( から ) (またはEOF) の間に置ける文字には制限があります。

  • 空白文字 (改行を含む) は ( の直後または ) (またはEOF) の直前にのみ書けます。これらの許可された位置では複数個の空白文字が許されます。
  • エスケープされていない ", ', (, およびnon-printable code pointは禁止されています。non-printable code pointは、ASCII文字のうち印字可能文字と(CSSの意味での)空白文字を取り除いたものです。
  • 行結合は禁止されています。

これらの禁止されている文字がある場合は <url-token> のかわりに <bad-url-token> が返されます。

/* OK */
background-image: url(https://example.com);

/* OK (url-tokenではなく、普通の関数呼び出し構文になる) */
background-image: url("https://example.com");

/* OK (改行・空白は最初と最後にしかない) */
background-image: url(
  https://example.com
);

/* NG (途中に空白がある) */
background-image: url(https:// example.com);

/* OK ( "[" は許可されている) */
background-image: url(https://example.com/?[);

/* NG ( "'" は許可されていない) */
background-image: url(https://example.com/');

/* OK (エスケープされている) */
background-image: url(https://example.com/\'\"\(\));

また、文字列の場合と同じく、EOFで終端されている場合はパースエラーになりますが、それだけでは <url-token><bad-url-token> に変化することはありません。

数系のトークンには以下の派生があります

  • <number-token> …… 123 など
  • <percentage-token> …… 123% など。 <number-token>% を連結したもの。
  • <dimension-token> …… 123px など。 <number-token><ident-token> を連結したもの。

は常に10進数であり、以下の並びです。ただし、「整数部」と「小数部」の少なくとも一方は存在する必要があります。

  1. 符号 (なくてもよい): + または -
  2. 整数部 (なくてもよい): 1つ以上の 09 の数字の並び
  3. 小数部 (なくてもよい): 以下の全ての並び
    1. .
    2. 1つ以上の 09 の数字の並び
  4. 指数部 (なくてもよい): 以下の全ての並び
    1. e または E
    2. + または -
    3. 1つ以上の 09 の数字の並び

「小数部」と「指数部」のうちいずれかがある場合はnumber型、いずれもない場合はinteger型として扱われます。

整数部 小数部 指数部
× × × 無効 無効
× × ✔️ 無効 無効
× ✔️ × .456 number
× ✔️ ✔️ .456e+7 number
✔️ × × 123 integer
✔️ × ✔️ 123e+7 number
✔️ ✔️ × 123.456 number
✔️ ✔️ ✔️ 123.456e+7 number

小数点は桁が後続するときだけ有効であるため、 1. は数ではありません。この場合は <number-token> たる 1<delim-token> たる . の並びとして認識されます。

HTMLコメント風トークン

<!-- (<CDO-token>) と --> (<CDC-token>) は独立したトークンとして解析されます。 -- は通常は識別子ですが、 --> の形のときは最長一致ルールによりこちらが優先されます。

このトークンの存在意義はひとつだけです。それは、スタイルシートの最上位で使われたとき、単にそれを無視するというものです。

この機能はCSS登場以前の <style> を解釈できないWebブラウザのために導入されたものです。<style> を解釈するWebブラウザと解釈しないWebブラウザの両方をサポートしたい場合、 <style> タグを以下のようにして記述することが想定されていました。

<style><!--
p { color: red; }
--></style>

<style> 登場以前に実装されたWebブラウザは、これを未知のタグに囲まれたHTMLコメントと解釈するため、何も起きません。いっぽう、 <style> を解釈できるWebブラウザは<style> タグ内でHTMLコメントを特別扱いせず、単なるテキストとして解釈するように実装されます。これにより、 <!----> を含む全体がそのままスタイルシートとして読み込まれます。

CSSによって余計な <!----> は、CSS側の構文解析で処理されます。これが <CDO-token> / <CDC-token> の役割です。

現代のまともなWebブラウザは (仮にCSSを実装していなくとも) <style> タグの存在を知っており、このタグを正しく処理できることは保証されています。そのため、現代でこのような書き方をする必要はありませんが、この時代に作られたWebページを正しく表示するためにこのルールは残されています。


脚注
  1. ここではわかりやすさのために、あえて現在ではほぼ普及している機能を例に挙げました。しかし、CSSは進化し続けているため、最新の機能についても同様のことを考える必要があります。 ↩︎

  2. 厳密に言えば、たとえばなんらかのvendor prefixのついたプラグマを用意するなどすれば安全に構文を拡張する余地はありそうですが、そこまで手間をかける意味はほぼないでしょう。 ↩︎

Discussion