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.tsimport 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.tsimport {
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.tsexport const CSS_PROPS = {
LINE_HEIGHT: 'line-height',
};
src/rules/lineheight-unit.rule.tsimport 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
という便利なメソッドが生えており、Rule
・AtRule
・Declaration
・Comment
といったtypeを指定しつつ探索できるので非常に便利。
上記ではwalkDecls()
でline-height
というプロパティを使用しているDeclaration
に絞りつつ、値がpx
の場合にreport()
を呼んでいる。
3. Ruleをexportする
src/types/utils.type.tsexport type NewableClass<T> = {
new (...args: any[]): T;
};
src/rules/index.tsimport 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.tsimport { RULES } from './rules';
module.exports = RULES.map((rule) => new rule().createPlugin());
これでRuleを外部から参照できるようになった。
src/rules/index.tsexport 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.tsimport {
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.tsimport { 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 },
],
},
],
});
accept
・reject
それぞれにテストケースとなる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に追記する。
npm i -D /path/to/stylelint-plugin-example
次に実際にStylelintを導入しているプロジェクトにて、今回実装したパッケージをインストールする。
module.exports = {
"plugins" : ["stylelint-plugin-example"],
"rules": {
"oteto/lineheight-unit": true
}
}
Stylelintのconfigファイルのplugins
・rules
に今回のものを追記すれば設定完了。
実際にCSSを書いてみると、今回実装したRuleが無事に警告されることが確認できた。