PostCSSというCSSのパースと変更を行うNode.jsパッケージを知った。Autoprefixerで使われている。名前からわかるように、主にCSSをポストプロセスするためのパッケージだけど、単にパーサーとして使っても良さそう。

習作ということでメディアクエリをまとめるライブラリ、CSS MQPackerを作った。パース結果のオブジェクトを把握するまでがちょっと時間かかったけど、その後は特に難しいことはなく、快適にCSSポストプロセッサーを書けそうな予感がする。

PostCSSのparse()を呼んだ返り値やprocess()で呼ばれる自前の関数に渡されるオブジェクトは、その子の配列rulesの各要素にルールセット(かメディアクエリなど)が格納され、それらは以下のような構造になっている。

{
  type: 'rule',
  decls: [
    {
      type: 'decl',
      parent: [Object],
      source: {
        start: [Object],
        end: [Object]
      },
      before: ' ',
      prop: 'color',
      between: ': ',
      _value: 'black'
    }
  ],
  parent: {
    type: 'root',
    rules: [Circular],
    after: ''
  },
  source: {
    start: [Object],
    end: [Object]
  },
  before: '',
  _selector: '.foo',
  between: ' ',
  semicolon: true,
  after: ' '
}

ほとんどは元のCSSファイルと同じ書式で出力されるように頑張るためのプロパティー。beforebetweenafterを使ってCSSを整形し直すとかは既にgrunt-cssprettyというGruntプラグインがある。

ルールセットの場合は_selectorにセレクターがそのまま入る。複数あっても配列になったりはしない。各ルールは子の配列declsemicolonは最後のルールがセミコロンで終わっていたら作られる(終わっていない場合はキーが作られない)。parentは親にあたる箇所への参照で、declにもあり、多くの場合はparent.append()parent.prepend()でルールやルールセット丸ごとを追加していくことになる。

sourceはルールの開始と終了位置で、パースする時にファイル名を渡すとそれも格納される。これを元にパース・エラーや好ましくない書き方の警告などもできるので、オレオレCSSLintや簡単な書式チェッカー(コロンの後に半角スペースが必ずあるかとかインデントの崩れとか)とか作るのに使える。

これら以外に破壊しながらイテレートするためのeach()を始めとする便利メソッドや、ルールを追加・削除・挿入するメソッドがぶら下がっている。

@importルールや@font-faceルール、そしてメディアクエリはatrule、コメントはcommentになり、構造もちょっと違う。

ドキュメントがちょっとわかりづらいので、コード読みつつ色々やってみないとわからないかも。


習作として作ったCSS MQPackerは散乱している同じメディアクエリのルールを後ろにまとめる。全ルールを調べて、typeatRulenamemediaの場合にだけ一旦記録しておき、後で同じメディアクエリが出てきたら、そこに記録しておいたメディアクエリのルールだけを挿入し、元のメディアクエリを削除することによって後ろへ後ろへとまとめていく。

スクリプトは以下のようにpack()をCSSコードを引数に呼ぶだけ。PostCSSのprocess()と同じ引数を取れるようにしたので、Source Mapの作成・更新も可能だと思う(自動判別も含めて)。

#!/usr/bin/env node

'use strict';

var fs = require('fs');
var mqpacker = require('css-mqpacker');

var filenameIn = 'in.css';
var filenameOut = 'out.css';

var css = fs.readFileSync(fileNameIn, {
  encoding: 'utf8'
});
var processed = mqpacker.pack(css, {
  from: fileNameIn,
  to: filenameOut,
  map: true
});
fs.writeFileSync(filenameOut, processed.css);
fs.writeFileSync(filenameOut + '.map', processed.map);

例えば以下のようなin.cssがあると、

.foo:before {
  content: "foo on small";
}

@media (min-width: 769px) {
  .foo:before {
    content: "foo on medium";
  }
}

.bar:before {
  content: "bar on small";
}

@media (min-width: 769px) {
  .bar:before {
    content: "bar on medium";
  }
}

以下のようにメディアクエリが後ろにまとめられてout.cssとして吐かれる。

.foo:before {
  content: "foo on small";
}

.bar:before {
  content: "bar on small";
}

@media (min-width: 769px) {
  .foo:before {
    content: "foo on medium";
  }
  .bar:before {
    content: "bar on medium";
  }
}

/*# sourceMappingURL=out.css.map */

同時にSource Mapファイルも正常に吐かれる。

{
  "version": 3,
  "file": "out.css",
  "sources": ["in.css"],
  "names": [],
  "mappings": "AAAA;EACE,yBAAwB;EACzB;;AAQD;EACE,yBAAwB;EACzB;;AAED;EATE;IACE,0BAAyB;IAC1B;EAQD;IACE,0BAAyB;IAC1B;EACF"
}

このパッケージを使ったGruntプラグインも作っておいた。


PostCSSでの既存のSource Mapファイルとの連携とかはまだちゃんと理解していないんだけど、mapオプションで、真偽値の代わりに大元からのSource Mapファイル(例えばSassファイルと処理対象のCSSファイルとのSource Mapファイル)を読み込んで指定すると、うまいこと更新してくれたりもする。ちょっと複雑なようなので、PostCSSを使って何かポストプロセスするツールを書く場合はprocess()にそのまま通すように作り、PostCSSに丸投げすると良いような気がする。