StylelintのPluginをテストしながら実装する【TypeScript】
業務内で改善活動の一環としてStylelintのPluginを実装する機会があり、プライベートでもオリジナルのものを作りたくなった。そこで今回はシンプルな「line-height
の単位にpx
を使用するのを禁止する」というRuleを実装してみる。
ちなみに公式Docの例はVanilla JSで書かれているが、今回はTypeScriptで実装する。
0. 環境構築
{ "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" }}
{ "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" }}
{ "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の抽象クラスを実装
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;};
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を実装する。
export const CSS_PROPS = { LINE_HEIGHT: 'line-height',};
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
という便利なメソッドが生えており、Rule
・AtRule
・Declaration
・Comment
といったtypeを指定しつつ探索できるので非常に便利。
上記ではwalkDecls()
でline-height
というプロパティを使用しているDeclaration
に絞りつつ、値がpx
の場合にreport()
を呼んでいる。
3. Ruleをexportする
export type NewableClass<T> = { new (...args: any[]): T;};
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];
import { RULES } from './rules';
module.exports = RULES.map((rule) => new rule().createPlugin());
これでRuleを外部から参照できるようになった。
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. 共通処理の実装
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を検証する
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 }, ], }, ],});
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 totalTests: 3 passed, 3 totalSnapshots: 0 totalTime: 0.818 s, estimated 1 sRan all test suites.
npm run test
してみると、無事にline-height
のpx指定がrejectされることが確認できた。
5. Pluginとして導入
今回作ったPluginとRuleを実際に利用してみる。
"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が無事に警告されることが確認できた。