CSSをクエリ言語として見る
(evdc.me)- CSSセレクタと宣言は、すでに存在する要素の集合を選び、その結果に属性を適用するという点で、関係を問い合わせて事実を生成するDatalogと構造的につながっている
- 通常のCSSは、選択結果を再び選択条件として使う再帰計算をサポートしていないため、darkテーマが子孫へ伝播し、lightの境界で止まるような状態伝播を直接表現できない
- 仮想的なCSSLogでは、セレクタのマッチに影響するクラス追加を許可し、
.effectively-darkのような派生状態を再帰的に伝播させ、新しい結果がこれ以上生まれなくなるまで反復計算することになる - このような計算はDatalogのfixpointとmonotonicityで説明でき、事実を削除せず追加だけを行う必要があるため、反復評価を有限時間で終えられる
- 実際のCSSのcontainer queriesも祖先の状態を読めはするが、派生した再帰状態を問い合わせることはできず、CSSはDatalogに近づく地点までは進んでも、ブラウザのレンダリングエンジンの境界は越えない
CSSとDatalogの基本的な対応
- CSSはすでに存在する対象を前提としており、ここではHTML要素がその対象になる
h1、a、divのような要素はCSSの外側ですでに存在しており、CSSはそれらを新たに宣言しない- 例として、
class、id、data-custom-attributeのような属性を持つ要素が挙げられている
- CSSセレクタは共通条件を持つ集合を指し、タグ名・id・class・属性値で対象を絞り込める
div、#child、.awesome、[data-custom-attribute="foo"]のようなセレクタが例として登場する- 文書階層における位置関係でも対象を表せ、セレクタの結合によって積集合も作れる
div.awesomeのような複合セレクタは集合の交差を行い、divであり同時に.awesomeでもある要素だけを選ぶ- この交差の概念は、後でDatalogのjoinに対応する重要なポイントにつながる
- CSSルールはセレクタと宣言を束ねて、選ばれた集合に属性値を適用する
div.awesome { color: red; font-size: 24px }の例では、その要素群のcolorとfont-sizeを設定する- ブラウザではその結果として大きな赤いテキストがレンダリングされる
CSSの限界と再帰クエリの問題
- 通常のCSSは、言語の外にある属性を変えることには強いが、その変更結果を再び選択条件に直接使うことはできない
- 要素の色を設定することはできても、
div[color=red]のように色そのものをセレクタ条件にする例はブラウザに拒否される color: redな要素に再びcolor: blueを適用するルールは意味が曖昧になる
- 要素の色を設定することはできても、
- デザインシステムのダークモード例は、推移的な状態伝播が必要な問題として示される
data-theme="dark"のカード内部にあるすべてのインタラクティブ要素へ白いフォーカスアウトラインを適用したい- ただし途中に
data-theme="light"があれば、その下への伝播は止まらなければならない
- 実際のCSSでは、例外を付け足す方式で一部だけ対処できる
[data-theme="dark"] :focus { outline-color: white; }で基本ルールを作り[data-theme="dark"] [data-theme="light"] :focus { outline-color: black; }でlight境界を打ち消せる- しかしこの方法では、ネストが深くなるほどルールを追加し続ける必要がある
- この問題には再帰的な関係定義が必要だが、CSSではそれを表現できない
- 「自分自身がdarkであるか、あるいはeffectively-darkな祖先を持ち、その間にeffectively-lightな祖先がない場合にeffectively-darkである」という定義が必要になる
- 原文ではこれをrecursive relational definitionと呼び、CSSでは表現できないと断言している
CSSLogの仮想構文
- CSSLogは通常のCSSのようにセレクタと属性設定を保ちつつ、セレクタのマッチに影響する属性まで変更できる仮想版として示される
- 例では
class: +barのようにクラスを追加する構文が登場する - 新しい子要素を作る
+<div class="baz">のような形式も仮定されている - 要素の削除は「たぶんできないだろう」とだけ書かれており、追加説明はない
- 例では
div.fooルールが実行された後に同じ要素がdiv.barにもマッチするように、ルール実行結果が次のマッチに影響するようになる- この時点で、一度の順方向適用だけでは終わらず、反復計算が必要になる
- ダークモードの例をCSSLogに移すと、派生クラスの再帰伝播が可能になる
[data-theme="dark"] { class: +effectively-dark; }で始め.effectively-dark > :not([data-theme="light"]) { class: +effectively-dark; }で子へ伝播させる.effectively-dark :focus { outline-color: white; }で最終スタイルを適用する
- 2つ目のルールは、light境界に達するまで再帰的に伝播し、望む状態に到達すると停止するものとして描かれる
- 原文では、現在のCSSではこのような動作はできないと書かれており、終盤で一部似た回避策も再び扱われる
Datalogの構造とCSSとの類似性
- Datalogでは対象はatomsと呼ばれ、最初に言及された瞬間に存在する
alice、bobのような名前は別途宣言なしでそのまま使われる- Rubyの
:symbolsと比較する文も添えられている
- 集合と関係はrelationsとtuplesで表現される
parent(alice, bob)はparent関係内の1つのタプルになるparentは「1番目の対象が2番目の対象の親である」という組の集合として説明される
- 変数はクエリマッチングと集合選択に使われる
parent(bob, X)はBobが親であるすべてのXを意味する- この例では
Xはcarolとdaveに評価される - 慣例として、変数は大文字、atomとrelationは小文字を使う
- 同じ変数名を繰り返すとjoinが起きる
mother(X, Y) :- parent(X, Y), woman(X).は、親の集合と女性の集合の交差によって母の関係を作る- 本文ではこれを「すべての親」と「すべての女性」の積集合として説明している
- Datalogルールの
:-はifとして読まれ、右側の本文条件がすべて成り立つと、左側headの事実を真として追加する- bodyのカンマはandとして読まれる
ancestor(X, Y) :- parent(X, Y).は、XがYの親なら祖先であることを意味する
- CSSとDatalogは形だけ反転した類似構造として比較される
color(X, red) :- div(X), class(X, awesome).は「divでありawesomeクラスでもあるXの色はred」となる- CSSの
div.awesome { color: red; }との意味対応が示される - 原文では、selectorがbodyで、declarationがheadだとまとめている
再帰と派生事実
- Datalogで何かを「する」とは、新しい事実を派生することを意味する
- 既存の事実にもとづいて関係に新しいタプルを追加する形で動く
ancestorの例は、再帰ルールの典型として示されるancestor(X, Y) :- parent(X, Y).は直接の親を祖先にするancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).は親の祖先関係をたどって上へ拡張する
- 2つ目のルールは、
ancestorがheadとbodyの両方に現れる自己参照再帰を含む- このルールにより、
alice -> bob -> carol、alice -> bob -> daveのような間接祖先関係が派生する
- このルールにより、
- 実行例の結果として、次の5つの事実が示される
ancestor(alice, bob)ancestor(bob, carol)ancestor(bob, dave)ancestor(alice, carol)ancestor(alice, dave)
- SQLも
WITH RECURSIVE以前はこのような計算ができず、この機能は再帰計算の需要から生まれたと書かれている- ただし原文では、SQLの再帰構文や意味論は他の部分とうまく合成できるとは限らないとも付け加えている
- Datalogでは
forループがなくても、必要な結果がすべて出るまでエンジンが自動的に計算を続ける- 次の節で、その理由をfixpointへとつなげている
Fixpointと単調性
- 通常のCSSのcascadeは、一度きりの順方向適用として描かれる
- ブラウザがルールを読み、セレクタマッチングを計算し、宣言を適用して終わる
- フィードバックループはない
- CSSLogと実際のDatalogでは、あるルールの結果が別のルールの条件を再び満たしうる
- あるルールが属性を変え、その属性のせいで別のルールが再実行され、さらに最初のルールにも影響する構造が可能になる
- 素朴なDatalogエンジンは、新しい事実がこれ以上生まれなくなるまでルール適用を繰り返す
- 明示されたbase factから始める
- すべてのルールのbodyを現在の事実集合とマッチさせる
- 一致した場合はheadの事実を追加する
- 新しい事実が生まれたら再度繰り返し、なければ終了する
- この終了地点をfixpointと呼ぶ
- すべてのルールをもう一度適用しても新しい結果が出ない状態になる
ancestorの例は3ラウンドで整理される- 最初のラウンドでは、
parentの事実から直接の祖先関係が3つ追加される - 2回目のラウンドでは、
aliceの間接祖先関係が2つ追加される - 3回目のラウンドでは、新しい事実がもう生まれずfixpointに到達する
- 最初のラウンドでは、
- 終了可能な理由はmonotonicityにある
- 事実を削除せず、追加しかしないため、既知の事実集合は増え続けるだけになる
- 有限の初期事実と有限の派生可能性のもとでは、有限回の処理で停止できる
- 逆に事実の削除が許されると、後の結果が前の結果を覆す状況が生じ、この性質が崩れる
- 原文ではこれをInfinite Loop Landと表現し、そのためCSSLogも要素削除を許さない方がよいと結びつけている
- 分散システムでも単調性は、高価な調整なしで一貫性を得る性質と関係すると脚注で補足している
- Consistency As Logical Monotonicity関連リンク 1: 分散システムと単調性
- 関連論文リンク 2: 参考資料
なぜ重要なのか
- CSSとDatalogの比較は、異なる分野にある同じ構造をあらわにする
- どちらにも対象があり、その対象の集合を問い合わせ、その結果として何かを適用したり新しい事実を作ったりする共通点がある
- DatalogとPrologは1970年代から、関係データベースと当時のAI研究の中で登場し、その後さまざまな形で再発明されてきた
- Datomic
- Differential Datalog
- さまざまなrule engineにも触れられている
- 「対象があり、集合を記述でき、その対象に操作を行える」システムを作ると、似た地点へ収束するという観察が込められている
- データベース・ロジックプログラミング分野とフロントエンドWeb開発分野は、互いにあまり接続されていない
- もっとつながれば、新しい何かを一緒に作り出せるかもしれないという期待も添えられている
Container Queriesと実際のCSSの境界
- この議論は、実際のCSS機能であるContainer Queriesにもつながっている
- 親や祖先要素のスタイルを基準に、現在の要素へスタイルを適用できる
- 例として
@container style(--theme: dark) { .card { background: royalblue; color: white; } }が示されている
- しかし推移的なダークモード問題は、単純な祖先参照よりも強い計算を必要とする
- 各要素が自分自身がeffectively darkかどうかを知る必要がある
- その状態が子孫へ推移的に伝播しなければならない
data-theme="light"境界で伝播が止まらなければならない
- Container queriesは派生状態を読めない
- 祖先のカスタム属性値は参照できても、他のルールがすでに計算したeffectively-darkのような状態は問い合わせられない
- DOMにすでに存在する状態だけを読めるのであって、再帰計算の結果は見られない
- したがって、「どこかの祖先が推移的にdarkで、かつそれより近いlight祖先がなければ適用する」といったクエリは、再帰が必要なため実装できない
- 原文ではcontainer queriesに再帰はないと明言している
- 2015年の記事では、element queriesが似た理由で何度も失敗してきた背景が扱われている
- クエリが設定する属性を再び問い合わせられるようになると、ループ、ひいては無限ループが生まれうる
- CSS Working Groupは、こうした問題を情報の流れる方向の制限によって解いてきたとまとめられている
- 子孫は祖先の情報を問い合わせられるが、逆方向は許されない
- こうすることで、fixpoint意味論なしでも有限性を維持できる
- 情報はツリーを下る方向にだけ伝播し、新しいbase factを投入しない構造に保たれる
- 原文では、CSSがDatalogエンジンに近づきながらも、意図的にそこまでは行かない様子としてこの流れを描いている
- CSSLogは循環を許しfixpointまで評価しようとする側であり
- 実際のCSSはブラウザのレンダリングエンジンであって、増分関係データベースエンジンではない、という線で止まる
別方向の可能性
- ブラウザにDatalog意味論を入れる代わりに、Datalogの上にCSS構文を載せる方向もありうる
- Datalogの
:-、ピリオド、大文字小文字の慣例、代入文の不在といった構文は、現代の言語ユーザーには参入障壁になりうる
- Datalogの
- CSSはすでにツリー構造を直接扱う構文を持っている
- 子孫・子・兄弟の結合子があり、親子関係を自然に表現できる
- ふつうのDatalogでは、こうした構造を関係形式にやや面倒にエンコードしなければならない
- 多くの実データがツリー形であることも強調される
- JSON
- AST
- ファイルシステム
- 組織図
- XMLが例として列挙される
- fixpoint再帰、CSS風構文、暗黙の親子関係を組み合わせたツールがあれば、再帰的なツリークエリをよりなじみある記法で書けるようになる
- 原文時点では、そのようなツールはまだ誰もきちんと作っていないようだと述べている
- もっと良い名前の「CSSLog」のような何かを、誰かが作ってみてもよさそうだという一文で締めくくられる
脚注
- HTML要素の単純化に関する脚注が付いている
- CSSが扱う対象は必ずしもすべてHTML要素だけではないという細かな反論を見越しつつ、本文では説明を簡単にするためHTML要素として扱っている
- naive evaluationは、すでに知っている事実まで毎回再計算するため非効率である
- 標準的な改善方法としてsemi-naive evaluationが言及される
- 各段階で新たに派生した事実だけを見るのがポイントである
- 無限ループ自体は、Turing-completeな言語では珍しいことではないとも付け加えられている
- JavaScriptでも
while true {}を書ける - ただしブラウザのレンダリングシステムが、Webサイト側のロジックの混乱のせいで永久に停止する事態は避けたい、という文脈が添えられる
- JavaScriptでも
- CSSのcustom property inheritanceによる回避策も脚注で扱われている
[data-theme="dark"] { --effective-theme: dark; }[data-theme="light"] { --effective-theme: light; }@container style(--effective-theme: dark) { :focus { outline-color: white; } }- この方法は、この特定のケースではおおむね機能するが、継承は真の推移閉包と同じではない
- 親子以外の属性チェーンに沿って推移閉包が必要な、より複雑な問題では破綻する
1件のコメント
Hacker Newsのコメント
CSSセレクタはXPathよりずっと書きやすい。
最近、PHPの新しいDOM APIがHTMLとCSSセレクタをネイティブに非常に簡単に扱えるようになったという発表もあった。以前はCSSをXPathに変換する必要があった。
[1] https://speakerdeck.com/keyvan/parsing-html-with-php-8-dot-4...
ブラウザのスタイリングを中心に発展してきたので、XPathのようなテキスト内容ベースの選択機能がないのは惜しい。
以前に提案はあったが、ブラウザのレンダリング文脈では性能上の問題が生じうるため、仕様には入らなかったと理解している。
ドキュメント編集エージェントを作る際、文書をHTMLで見せて、LLMがCSS selectorだけを指定して必要な断片をコンテキストに取り込めるようにしたところ、ほとんど魔法のようにうまく動いた。
人々が慣れたやり方をそのまま使える。
CSS文法と、CSSWGが定義する規則・関数・単位などの全体体系を分けて呼ぶ名前があるとよいと思う。
この分野にはかなり可能性があるが、別の活用例を語ったり調べたりしようとすると、結局はCSSパーサを含むGitHubコードを掘って、人々がどんな奇妙なものを作っているのか確認するしかないように見える。
軽量なノードベースのマークアップ言語と、テンプレートに何が入るかを表現するCSSセレクタ、そしてこれらの断片をどう結合するかを制御するCSS風の文法を混ぜた、奇妙なテンプレートエンジンのようなものもいじっている。
https://www.w3.org/TR/selectors-3/
DOM仕様もこれを参照している。
https://dom.spec.whatwg.org/#selectors
だからCSS selectorという総称はすでに正しく、単にselectorと呼んでもよい。
DOM selectorという名前のほうがすっきりしているかもしれないが、静的CSSで使うセレクタや、JSエンジン外の別のDOMエンジン(XML parser、PHP DOM APIなど)で使うセレクタまで考えると、かえって混乱を招くかもしれない。
さらに、
:hoverや::target-textのようにブラウザのレンダリング・ナビゲーションに直接結び付いた特殊セレクタもある。ただし、ブラウザやCSSとの結び付きが弱い最小クエリ文法の部分集合には別名があると便利かもしれない。
以前カンファレンスで見たhttps://github.com/braposo/graphql-cssを思い出した。
ジョークプロジェクトだったが、パターンを別の文脈に移植して再利用することで意外なことが可能になる、という点をよく示していてよかった。
まさにそういうふうに、異なる文脈のパターンを持ち込んで使ってみようとしているところだ。
大半は大したところまで行かなくても、ハッカー的な感覚ではかなり興味深い。
pyastgrepはhttps://pyastgrep.readthedocs.io/en/latest/で見られるように、Python文法をクエリする際にCSSセレクタを使える。
デフォルトはXPathで、たとえば
pyastgrep --css 'Call > func > Name#main'のように使える。自分が指し示したかった方向性とほぼ正確に重なっている。
これがどんなシナリオを解決するのかよく分からない。
今でも子要素に応じて親を条件付きで変えられる。たとえば
preはデフォルトのパディングが16pxで、直接の子がcodeなら&:has(> code)で0にできる。結論も「現代のCSSの限界を直すべきだ」ではなく、CSS風の文法をDatalog風のシステムに載せれば、ツリー状データを扱う仕事をより多くのエンジニアにとって親しみやすくできるのではないか、というものに近い。
つまりDOMに新しい子要素や属性を追加する方向の話である。
今のLLMはCSSをあまりうまく扱えないので、むしろこれを試すことでLLMがより単純に推論できるか確かめてみたくなる。
実用性はあまり思い浮かばないが、それでもかっこいい。
うーん……これは単にJQなのではと思う。
CSSはある程度好きだが、だんだん複雑性のクリープがひどくなっていくのは嫌いだ。
プログラミング言語が非プログラミング言語より強力になるという理屈は理解できるが、HTML・CSS・JavaScriptを延々と複雑にしていくくらいなら、いっそそれ全体を置き換える何か別のものが出てくるほうがよいと感じる。
HTML5の新要素も大半はなぜ必要なのかよく分からず、ほとんど使っていない。結局、多くのコンテナは固有IDが付いた
divにすぎないと思うようになったし、内部リンク用のhrefナビゲーションのために、そうしたIDに別名のようなものがあればいいのにとさえ思った。[data-theme="dark"] [data-theme="light"] :focus { outline-color: black; }のようなものも、頭の中で解釈するのに時間がかかりすぎて、もはや優雅で単純には感じられない。その一方で
h2 { color: red; }は今でも単純だ。ancestor(X, Y) :- parent(X, Y).のような表現も、もう考えたくなくなる。:-はいったい何なんだ、笑顔みたいに見える。@container style(--theme: dark) { .card { background: royalblue; color: white; } }を見たところで読むのをやめた。昔はうまく機能していた標準が、時間がたつほど壊れていくようで奇妙だ。
たとえば
[data-theme="dark"] [data-theme="light"] :focus { outline-color: black; }は英語風の擬似コードにすれば、data-theme="dark"な X がいて、その子 Y がdata-theme="light"で、さらにフォーカス状態なら、Y のoutline-colorを black にせよ、という意味に近い。だからこれはDatalog風に
outline-color(Y, black) if data-theme(X, "dark") and parent(X, Y) and data-theme(Y, "light") and focused(Y)のように書ける。:-をifに、カンマをandに置き換えたようなものだ。さらに
Y.outline_color := black if X.data-theme == dark and Y.parent == X and Y.data-theme == dark and Y.focusedのように書いて、attr(X, val)をX.attr == valのようなUFCS風の構文糖衣として見せることもできる。もっとALGOL系っぽく見せるなら
forall Y { Y.outline_color := black if Y.data_theme == "dark" and Y.focused and Y.parent.data_theme == "light" }のようにもできる。ここではYを明示的に導入し、結合の一つを暗黙化して、より一般的なプログラミングのように見せているが、実際にはDatalogエンジンが依存関係が変わるたびにこうしたループを効率的に回してくれる、というわけだ。