Closed19

stylelint-pluginを作ってみる会

JJJJ

今回作るのは、 margin: 0px みたいに 0px を指定した時 0 に統一するためのruleをつくってみる。(簡単そうだから)

JJJJ

そもそも 0px の時に 0 とするのが正しいのかを調べないといけない。ここの話は W3C Values and Units Module Level3 というとこに書いている。

For zero lengths the unit identifier is optional (i.e. can be syntactically represented as the <number> 0). However, if a 0 could be parsed as either a <number> or a <length> in a property (such as line-height), it must parse as a <number>.

0 に対する単位識別子は任意である。しかし、 line-height など number もしくは length のどっちとしてもパースできるようなプロパティの値の場合は、 number としてパースする。

と書いてあるので、 0 と書いても px を省くことができる。

JJJJ
index.js
const stylelint = require("stylelint");
const ruleName = "no-zero-unit-ident";
const messages = stylelint.utils.ruleMessages(ruleName, {
  expected: "Expected...",
});

module.exports = stylelint.createPlugin(
  ruleName,
  function (primaryOption, secondaryOptionObject) {
    return function (postcssRoot, postcssResult) {
      const validOptions = stylelint.utils.validateOptions(
        postcssResult,
        ruleName,
        {}
      );
      console.log({
        primaryOption,
        secondaryOptionObject,
        postcssRoot,
        postcssResult,
        validOptions,
      });
      if (!validOptions) {
        return;
      }
    };
  }
);

module.exports.ruleName = ruleName;
module.exports.messages = messages;

とりあえずもらえるものが何かわからないから console にだしておいて、ユーザーにwarnなりを出すのは stylelint.utils.report ってやつっぽい。

JJJJ

return functionにしてるやつはPostCSS Pluginのものがかえってくるのかな。ASTとLazyResultってかいてる。

ruleFunction should return a function that is essentially a little PostCSS plugin. It takes 2 arguments:
the PostCSS Root (the parsed AST)
the PostCSS LazyResult
You'll have to learn about the PostCSS API.

JJJJ
index.test.js
const { messages, ruleName } = require("./");

testRule({
  plugins: ["./index.js"],
  ruleName,
  config: true,
  fix: false,

  accept: [
    {
      code: ".class { margin: 0; }",
    },
  ],

  reject: [
    {
      code: ".class { margin: 0px; }",
      message: messages.expected(),
    },
  ],
});

ざっとこんな感じでかいてみた。公式のdocsに書いてる fixtrue になっているが、これは多分fixerを実行するかどうかのoptionなので今は false にしておく。あとでfixerを実装した時に true にすればいい。

JJJJ

ここでいくつかエラーが出たのでそのエラーと解決策を載せておく。

stylelint v7+ requires plugin rules to be namespaced, i.e. only plugin-namespace/plugin-rule-name plugin rule names are supported. The plugin rule "no-zero-unit-ident" does not do this, so will not work.

なんかstylelint v7+からはplugin-rule-nameの前にnamespaceがいるっぽい。なので、 index.jsruleName の部分を少し変えてみる。

- const ruleName = "no-zero-unit-ident";
+ const ruleName = "konojunya/no-zero-unit-ident";

これで問題ない。

なんか一回テストが通ったらもう出なくなってしまったけどテストコードの

index.test.js
  accept: [
    {
      code: ".class { margin: 0; }",
    },
  ],

この部分や、rejectの同じ部分は no-description とでてくる。

index.test.js
  accept: [
    {
      code: ".class { margin: 0; }",
      description: "The unit identifier does not exist.",
    },
  ],

なので description を書いてあげるとこのように accept の方は通ってくれる。

rejectの方はまだreportの実装をしてないので通らない。とりあえずこれで一旦実装のほうにうつる。

JJJJ

とりあえずconsole.logしておいたものの中身をみてみる。2回実行されてるが、これはacceptとrejectの2回実行してるからである。 px の記述のほうを深掘りしたいので、一旦2回目の方のログをはる

    {
      primaryOption: true,
      secondaryOptionObject: undefined,
      postcssRoot: Root {
        raws: { semicolon: false, after: '' },
        type: 'root',
        nodes: [ [Rule] ],
        source: { input: [Input], start: [Object], syntax: [Object], lang: 'css' },
        lastEach: 2,
        indexes: {}
      },
      postcssResult: Result {
        processor: Processor { version: '7.0.35', plugins: [] },
        messages: [],
        root: Root {
          raws: [Object],
          type: 'root',
          nodes: [Array],
          source: [Object],
          lastEach: 2,
          indexes: {}
        },
        opts: { from: undefined, syntax: [Object] },
        css: '.class { margin: 0px }',
        map: undefined,
        stylelint: {
          ruleSeverities: [Object],
          customMessages: [Object],
          disabledRanges: [Object],
          stylelintError: false,
          quiet: undefined,
          config: [Object]
        }
      },
      validOptions: true
    }

css ってとこにテストコードで書いてるコードが入っている。ちゃんと .class { margin: 0px } と書かれている。この中身を深掘りたいが、その前に postcssRootpostcssResult が中身が似てるので何が違うのか確認したい。

postcssRoot は普通にASTで、 postcssResult はこれっぽい。
https://postcss.org/api/#result

Provides the result of the PostCSS transformations.

postcssで変換された後のコードを指しているらしい。
とりあえず今回は postcssRoot を見ていきながら書いてみたいと思う。

ASTを掘っていきたいが、まず大体の目安を付けたい。

JJJJ

postcssRoot.nodes が怪しいので、その中身を出力してみる。

    {
      nodes: [
        Rule {
          raws: [Object],
          type: 'rule',
          nodes: [Array],
          parent: [Root],
          source: [Object],
          selector: '.class',
          lastEach: 2,
          indexes: {}
        }
      ]
    }

配列は1つの Rule をというobjectを持った配列のようだ。

その中には

    {
      n: Rule {
        raws: { before: '', between: ' ', semicolon: false, after: ' ' },
        type: 'rule',
        nodes: [ [Declaration] ],
        parent: Root {
          raws: [Object],
          type: 'root',
          nodes: [Array],
          source: [Object],
          lastEach: 2,
          indexes: {}
        },
        source: { start: [Object], input: [Input], end: [Object] },
        selector: '.class',
        lastEach: 2,
        indexes: {}
      }
    }

こんな風になっていた。この中の nodes が怪しいのでそこまで掘ってみる。
この時点でわかっている情報は selector.class と書いているのでその中の nodes にはきっとその中のコードに関するものが入ってるっぽいことである。

    {
      n: Declaration {
        raws: { before: ' ', between: ': ' },
        type: 'decl',
        parent: Rule {
          raws: [Object],
          type: 'rule',
          nodes: [Array],
          parent: [Root],
          source: [Object],
          selector: '.class',
          lastEach: 2,
          indexes: {}
        },
        source: { start: [Object], input: [Input], end: [Object] },
        prop: 'margin',
        value: '0px'
      }
    }

ここまで掘れば Declaration というobjectで propmarginvalue0px があることがわかる。
ということは今

const n = postcssRoot.nodes[0].nodes[0];
console.log({ n });

と探索をしていたが、 Declaration というキーワードでASTのNodeを発見できればよさそうだ。あとはこのvalueが 0px0pt みたいな宣言をしてないかを確認して、もしそうなっていればreportすればいい。

JJJJ

PostCSS APIには walkDecls という関数が生えている。

https://postcss.org/api/#atrule-walkdecls

Traverses the container’s descendant nodes, calling callback for each declaration node.

nodeをトラバースしながら宣言ノードに到達したらコールバックを読んでくれるという神のような関数である。これを用いて検索してみよう。下のコードは module.exports している関数の中だけを記述する。

index.js
module.exports = stylelint.createPlugin(ruleName, function () {
  return function (postcssRoot, postcssResult) {
    const validOptions = stylelint.utils.validateOptions(
      postcssResult,
      ruleName,
      {}
    );

    postcssRoot.walkDecls((decl) => {
      console.log(decl);
    });

    if (!validOptions) {
      return;
    }
  };
});

これを実装したら yarn jest をしてテストコードを実行してみる。

    <ref *1> Declaration {
      raws: { before: ' ', between: ': ' },
      type: 'decl',
      parent: Rule {
        raws: { before: '', between: ' ', semicolon: false, after: ' ' },
        type: 'rule',
        nodes: [ [Circular *1] ],
        parent: Root {
          raws: [Object],
          type: 'root',
          nodes: [Array],
          source: [Object],
          lastEach: 3,
          indexes: [Object]
        },
        source: { start: [Object], input: [Input], end: [Object] },
        selector: '.class',
        lastEach: 3,
        indexes: { '3': 0 }
      },
      source: {
        start: { line: 1, column: 10 },
        input: Input {
          css: '.class { margin: 0px }',
          hasBOM: false,
          id: '<input css 2>'
        },
        end: { line: 1, column: 20 }
      },
      prop: 'margin',
      value: '0px'
    }

するとさっきと同じNodeの情報が取れている。これで全てのNodeに対して検索をかけることができたので、valueをみて 0px と記述してるかどうかをcheckしてみる。

JJJJ

今回は雑な正規表現で抜いてみる

index.js
postcssRoot.walkDecls((decl) => {
  console.log(decl.value.match(/(\d+)(px)/));
});

すると

[ '0px', '0', 'px', index: 0, input: '0px', groups: undefined ]

を、抜けたっぽい。ただこれのダメなところは他の単位指定をしたら抜けれるとこである。例えば 0pt とか。ということで仕様書を確認してCSSに出てくる単位識別子を列挙してみよう。
単位識別子は https://www.w3.org/TR/css-values-3/#absolute-lengths の部分である。

the cm, mm, Q, in, pt, pc, px units

と書いてるので今回はこの7つの識別子を対象とする

index.js
postcssRoot.walkDecls((decl) => {
  console.log(decl.value.match(/(\d+)(cm|mm|Q|in|pt|pc|px)/));
});

すると

[ '0px', '0', 'px', index: 0, input: '0px', groups: undefined ]

正常に抜き出せている。正規表現が \d+ になっているのは例えば 10px などと指定した際に \d では 0px にmatchしてしまうためである。

あとはmatchしたのでその数字が 0 かどうかを判定してもし0だった場合はreportする。

JJJJ
index.js
postcssRoot.walkDecls((decl) => {
  const matched = decl.value.match(/(\d+)(cm|mm|Q|in|pt|pc|px)/);
  if (!matched) {
    return;
  }

  if (matched[1] === "0") {
    console.log("report!");
  }
});

matchしない場合は対象外なのでそのまま return してしまう。これでテストを動かすと、今まで2個consoleが出ていたのが1つになっていると思う。これは正常なケースの場合は return されてconsoleまで届かないからである。ではこのままテストケースを一回増やしておきたい。

JJJJ

acceptの方には

{
  code: ".class { margin: 10px; }",
  description: "value is 10px",
},

を足してrejectの方には

{
  code: ".class { margin: 0pt; }",
  message: messages.expected,
  description: "It has an optional identifier.",
},

を足しておいた。

するとテストは2つ通って2つ落ちる。

あとはreportをしてみる。

JJJJ
index.js
if (matched[1] === "0") {
  stylelint.utils.report({
    ruleName,
    result,
    message: messages.expected,
    node: decl,
  });
}

これでreportしてみて、テストを動かしてみる。

全てのテストが通った🙆‍♂️

JJJJ

大まかにこれで理解できたのでcloseする。またGitHubに全体のコードはあげている。

このままこれを開発するなら仕様書に

if a 0 could be parsed as either a <number> or a <length> in a property (such as line-height), it must parse as a <number>.

とあるように line-height の考慮をしないといけないので、 decl.prop === "line-height" でearly returnとかしないといけなそうだけど一旦終わり。

このスクラップは2021/02/19にクローズされました