Oteto Blogのロゴ

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という便利なメソッドが生えており、RuleAtRuleDeclarationCommentといった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 },
      ],
    },
  ],
});
  • 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に追記する。

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が無事に警告されることが確認できた。