stylelint-pluginを作ってみる会
https://github.com/konojunya/no-zero-unit-ident にstylelintのruleを作ってみる
今回作るのは、 margin: 0px
みたいに 0px
を指定した時 0
に統一するためのruleをつくってみる。(簡単そうだから)
そもそも 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
を省くことができる。
stylelintは https://stylelint.io/developer-guide/plugins に plugin の作り方も書いてくれてるのでこれを参考にしながら実装していく。
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
ってやつっぽい。
最初の2つの引数は .stylelintrc
とかにruleを書いた時に渡optionっぽい。1つ目はLEVELで、もう1つは何か渡すoptionがあれば渡すって感じっぽい。
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.
テストは https://github.com/stylelint/jest-preset-stylelint を使って書くっぽい。テストコードを書いてそれが通るようにするのをゴールにしよう。
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に書いてる fix
は true
になっているが、これは多分fixerを実行するかどうかのoptionなので今は false
にしておく。あとでfixerを実装した時に true
にすればいい。
ここでいくつかエラーが出たのでそのエラーと解決策を載せておく。
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.js
の ruleName
の部分を少し変えてみる。
- const ruleName = "no-zero-unit-ident";
+ const ruleName = "konojunya/no-zero-unit-ident";
これで問題ない。
なんか一回テストが通ったらもう出なくなってしまったけどテストコードの
accept: [
{
code: ".class { margin: 0; }",
},
],
この部分や、rejectの同じ部分は no-description
とでてくる。
accept: [
{
code: ".class { margin: 0; }",
description: "The unit identifier does not exist.",
},
],
なので description
を書いてあげるとこのように accept
の方は通ってくれる。
rejectの方はまだreportの実装をしてないので通らない。とりあえずこれで一旦実装のほうにうつる。
とりあえず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 }
と書かれている。この中身を深掘りたいが、その前に postcssRoot
と postcssResult
が中身が似てるので何が違うのか確認したい。
postcssRoot
は普通にASTで、 postcssResult
はこれっぽい。
Provides the result of the PostCSS transformations.
postcssで変換された後のコードを指しているらしい。
とりあえず今回は postcssRoot
を見ていきながら書いてみたいと思う。
ASTを掘っていきたいが、まず大体の目安を付けたい。
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で prop
に margin
、 value
に 0px
があることがわかる。
ということは今
const n = postcssRoot.nodes[0].nodes[0];
console.log({ n });
と探索をしていたが、 Declaration
というキーワードでASTのNodeを発見できればよさそうだ。あとはこのvalueが 0px
や 0pt
みたいな宣言をしてないかを確認して、もしそうなっていればreportすればいい。
PostCSS APIには walkDecls
という関数が生えている。
Traverses the container’s descendant nodes, calling callback for each declaration node.
nodeをトラバースしながら宣言ノードに到達したらコールバックを読んでくれるという神のような関数である。これを用いて検索してみよう。下のコードは module.exports
している関数の中だけを記述する。
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してみる。
今回は雑な正規表現で抜いてみる
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つの識別子を対象とする
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する。
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まで届かないからである。ではこのままテストケースを一回増やしておきたい。
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をしてみる。
if (matched[1] === "0") {
stylelint.utils.report({
ruleName,
result,
message: messages.expected,
node: decl,
});
}
これでreportしてみて、テストを動かしてみる。
全てのテストが通った🙆♂️
大まかにこれで理解できたので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とかしないといけなそうだけど一旦終わり。