StylelintのPluginをテストしながら実装する【TypeScript】

業務内で改善活動の一環としてStylelintのPluginを実装する機会があり、プライベートでもオリジナルのものを作りたくなった。そこで今回はシンプルな「line-heightの単位にpxを使用するのを禁止する」というRuleを実装してみる。

ちなみに公式Docの例はVanilla JSで書かれているが、今回はTypeScriptで実装する。

0. 環境構築

package.json
{
"name": "stylelint-plugin-example",
"version": "1.0.0",
"main": "./dist/index.js",
"files": [
"./dist/"
],
"scripts": {
"build": "tsc",
"build:reset": "rm -rf ./dist && tsc",
"test": "jest",
"prettier": "prettier --write \"{src,test}/**/*.ts\"",
"eslint": "eslint \"{src,test}/**/*.ts\" --fix"
},
"dependencies": {
"stylelint": "^15.11.0"
},
"devDependencies": {
"@types/jest": "^29.5.1",
"@types/node": "^20.8.10",
"@types/postcss-js": "^4.0.3",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.7.0",
"jest-preset-stylelint": "^6.3.2",
"postcss-scss": "^4.0.9",
"prettier": "^3.0.3",
"ts-dedent": "^2.2.0",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
}
}
tsconfig.json
{
"include": ["src"],
"exclude": ["test"],
"compilerOptions": {
"module": "commonjs",
"target": "ES2021",
"strict": true,
"allowJs": true,
"declaration": true,
"incremental": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"removeComments": true,
"noImplicitAny": false,
"baseUrl": ".",
"outDir": "dist"
}
}
jest.config.json
{
"preset": "jest-preset-stylelint",
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "test",
"testEnvironment": "node",
"testRegex": ".*\\..*test\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage"
}

プロジェクトを作成し、上記のように各ライブラリ・TypeScript・Jestの設定を行う。

/
├── src
│ └── rules
│ └── lineheight-unit.rule.ts
│ └── ...
└── test
└── rules
└── lineheight-unit.rule.test.ts
└── ...

それから今回は上記のようにrulesディレクトリ下に各Ruleをクラスとして作成していく。

1. Ruleの抽象クラスを実装

src/types/rule.type.ts
import type { PostcssResult, Severity } from 'stylelint';
import type { Node } from 'postcss';
export type RuleName = `oteto/${string}`;
export type RuleMessages = { reject: string };
export type FormattedRuleMessage = `${string} (${RuleName})`;
export type RuleReportOptions = {
result: PostcssResult;
node: Node;
severity?: Severity;
message?: FormattedRuleMessage;
};
src/rules/base.rule.ts
import {
createPlugin,
type PostcssResult,
type Rule as stylelintRule,
type RuleBase,
type Severity,
utils,
} from 'stylelint';
import type { Root } from 'postcss';
import type {
FormattedRuleMessage,
RuleMessages,
RuleName,
RuleReportOptions,
} from '../types/rule.type';
/**
* ルールの抽象クラス
*/
export abstract class BaseRule {
protected abstract readonly _name: RuleName;
protected abstract readonly _messages: RuleMessages;
protected readonly _severity: Severity = 'error';
protected abstract validate(root: Root, result: PostcssResult): void;
get name(): RuleName {
return this._name;
}
get messages(): RuleMessages {
return this._messages;
}
get severity(): Severity {
return this._severity;
}
createPlugin(): ReturnType<typeof createPlugin> {
return createPlugin(
this.name,
this.createRuleFunc() as unknown as stylelintRule,
);
}
formatMessages(): RuleMessages {
return utils.ruleMessages(this.name, this.messages);
}
protected formatMessage(message: string): FormattedRuleMessage {
return `${message} (${this.name})`;
}
protected report({
result,
node,
message,
severity,
}: RuleReportOptions): void {
const reportMessage = message || this.formatMessages().reject;
utils.report({
ruleName: this.name,
severity: severity || this.severity,
message: reportMessage,
result,
node,
});
}
private createRuleFunc(): RuleBase {
return () => {
return (postcssRoot, postcssResult) => {
if (!utils.validateOptions(postcssResult, this.name)) return;
this.validate(postcssRoot, postcssResult);
};
};
}
}

各Ruleの基底クラスを実装する。

  • Rule名
  • 警告のメッセージ
  • 重大度(デフォルトではerror

の指定を必須とし、それぞれのRuleクラスでvalidateメソッドに判定のロジックを書いていく。

また各Ruleクラス内で警告を出す際に呼ぶreport()を実装しておく。

2. Ruleの実装

先程の`BaseRuleクラスを継承し、今回の目的のRuleを実装する。

src/consts/css-props.ts
export const CSS_PROPS = {
LINE_HEIGHT: 'line-height',
};
src/rules/lineheight-unit.rule.ts
import type { PostcssResult } from 'stylelint';
import type { Root } from 'postcss';
import { BaseRule } from './base.rule';
import { CSS_PROPS } from '../consts/css-props';
/**
* Rule:line-heightの単位にpxは使用不可
*/
export class LineheightUnitRule extends BaseRule {
protected readonly _name = 'oteto/lineheight-unit';
protected readonly _messages = {
reject: '"line-height" の単位にpxは使用できません',
};
protected validate(root: Root, result: PostcssResult): void {
root.walkDecls(CSS_PROPS.LINE_HEIGHT, (decl) => {
if (decl.value.endsWith('px')) {
this.report({ result, node: decl });
}
});
}
}

PostCSS.Nodeにはwalkxxxという便利なメソッドが生えており、RuleAtRuleDeclarationCommentといったtypeを指定しつつ探索できるので非常に便利。

上記ではwalkDecls()line-heightというプロパティを使用しているDeclarationに絞りつつ、値がpxの場合にreport()を呼んでいる。

3. Ruleをexportする

src/types/utils.type.ts
export type NewableClass<T> = {
new (...args: any[]): T;
};
src/rules/index.ts
import type { NewableClass } from '../types/utils.type';
import type { BaseRule } from './base.rule';
import { LineheightUnitRule } from './lineheight-unit.rule';
export const RULES: NewableClass<BaseRule>[] = [LineheightUnitRule];
src/index.ts
import { RULES } from './rules';
module.exports = RULES.map((rule) => new rule().createPlugin());

これでRuleを外部から参照できるようになった。

src/rules/index.ts
export const RULES: NewableClass<BaseRule>[] = [LineheightUnitRule];
export const RULES: NewableClass<BaseRule>[] =
[
LineheightUnitRule,
xxxRule,
];

もし今後別のRuleを追加していく場合は上記のようにRuleを追加していけばよい。

4. テストの実装

jest-preset-stylelintというStylelintのPluginをテストするためのJestプリセットがあるので、これを利用することで非常に簡単にRule毎の検証ができる。

4-1. 共通処理の実装

test/setup-test.ts
import {
getTestRule,
getTestRuleConfigs,
TestRule,
TestRuleConfigs,
} from 'jest-preset-stylelint';
import { BaseRule } from '../src/rules/base.rule';
import { NewableClass } from '../src/types/utils.type';
type Output = {
testRule: TestRule;
testRuleConfigs: TestRuleConfigs;
rule: BaseRule;
};
const PATH_INDEX = './src/index.ts';
export function setupTest(rule: NewableClass<BaseRule>): Output {
const testRule = getTestRule({ plugins: [PATH_INDEX] });
const testRuleConfigs = getTestRuleConfigs({ plugins: [PATH_INDEX] });
return { testRule, testRuleConfigs, rule: new rule() };
}

4-2. Ruleを検証する

test/rules/lineheight-unit.rule.test.ts
import { setupTest } from '../setup-test';
import dedent from 'ts-dedent';
import { LineheightUnitRule } from '../../src/rules/lineheight-unit.rule';
const { testRule, rule } = setupTest(LineheightUnitRule);
const { reject: message } = rule.formatMessages();
testRule({
ruleName: rule.name,
customSyntax: 'postcss-scss',
config: true,
accept: [
{
code: dedent`
.a {
line-height: normal;
line-height: 1.5;
line-height: 1.5em;
line-height: 1.5rem;
line-height: 150%;
}`,
},
],
reject: [
{
code: dedent`
.a {
line-height: 15px;
}`,
message,
line: 2,
column: 3,
},
{
code: dedent`
.a {
line-height: 15px;
.b {
line-height: 0px;
}
}`,
warnings: [
{ message, line: 2, column: 3 },
{ message, line: 4, column: 5 },
],
},
],
});
  • acceptrejectそれぞれにテストケースとなるCSSを記載
    • rejectには予期するメッセージの指定が必須
  • SCSSを対象とする場合customSyntax: 'postcss-scss'の指定が必須
PASS test/rules/lineheight-unit.rule.test.ts
oteto/lineheight-unit
accept
true
'.a {\n' +
' line-height: normal;\n' +
' line-height: 1.5;\n' +
' line-height: 1.5em;\n' +
' line-height: 1.5rem;\n' +
' line-height: 150%;\n' +
'}'
✓ no description (33 ms)
reject
true
'.a {\n line-height: 15px;\n}'
✓ no description (2 ms)
'.a {\n line-height: 15px;\n .b {\n line-height: 0px;\n }\n}'
✓ no description (2 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 0.818 s, estimated 1 s
Ran all test suites.

npm run testしてみると、無事にline-heightのpx指定がrejectされることが確認できた。

5. Pluginとして導入

今回作ったPluginとRuleを実際に利用してみる。

package.json
"scripts": {
"prepare": "npm i typescript --save-dev && npm run build:reset",
},

npm install後にbuildされるようにnpm-scriptsに追記する。

Terminal window
npm i -D /path/to/stylelint-plugin-example

次に実際にStylelintを導入しているプロジェクトにて、今回実装したパッケージをインストールする。

module.exports = {
"plugins" : ["stylelint-plugin-example"],
"rules": {
"oteto/lineheight-unit": true
}
}

Stylelintのconfigファイルのpluginsrulesに今回のものを追記すれば設定完了。

今回実装したRuleが無事に警告されることが確認できた

実際にCSSを書いてみると、今回実装したRuleが無事に警告されることが確認できた。