eslint-plugin-jsx-a11yを少し調べる

転職活動中につき更新を控えていますが、更新します。

ESLintのプラグイン"eslint-plugin-jsx-a11y"は、jsx(tsx) にアクセシビリティのルールを追加します。
https://github.com/evcohen/eslint-plugin-jsx-a11y

今回は、これの仕組みと課題を知る必要があったので知ります。
(実際に課題を達成した記事ではありません)

ESLintのルールについて

ESLintは、構文からASTを作成し、rules(以下: ルール)を元にチェックを行なっていきます。
AST(抽象構文木)については、下記を参考にしてください。
JavaScript ASTを始める最初の一歩 | Web Scratch

ルールの実装はシンプルです。
情報を記載するmetaオブジェクトと、contextを受け取るcreate関数をexportするだけです。
create関数のcontextは、様々なユーティリティ関数を含むオブジェクトです。
その中ではnodeを仮引数として受け取っていて、メソッド内でnodeの情報を取得し、具体的なルール内容の実装をしていきます。

rule-example.js
module.exports = {
  meta: { /* 情報が入る */ },
  create: function(context) {
    return {
      hoge: function (node) {
        /* ルールの実装 */
      }
    }
  }
}

nodeを引数として受け取るためには、構文からASTを生成するparserが必要になります。
ASTのコミュニティ標準としてESTreeがあるのですが、これのJSX向けに定義されているものがあります。
https://github.com/facebook/jsx/blob/master/AST.md

eslint-jsx-a11yでも同じ仕組みがあるものと思って、次へ進みます。

eslint-jsx-a11yの仕組み

実装がわかりやすいルールを見ていきます。
下記は「htmlにはlang属性を付けて」というhtml-has-langルールのcreate関数です。

src/rules/html-has-lang.js
create: context => ({
    JSXOpeningElement: (node) => {
      const type = elementType(node);

      if (type && type !== 'html') {
        return;
      }

      const lang = getPropValue(getProp(node.attributes, 'lang'));

      if (lang) {
        return;
      }

      context.report({
        node,
        message: errorMessage,
      });
    },
  }),

引数にあるnodeは、HTMLのnodeについて、下記のようなオブジェクトをとります。

example.js
/**
  *  <html></html>
/ *
{
  type: 'JSXIdentifier',
  name: 'html',
  range: [ 180, 184 ],
  loc: { start: [Object], end: [Object] },
  parent: [Circular] },
  attributes: [],
  ...省略
}

最初にelementType()関数にnodeを渡し、nodeの要素名を返します。
返り値をtypeに渡し、その値がhtmlでなければ何もしません。
値がhtmlだった場合はgetPropValue()関数で、nodeがlang属性の値を持つか確認します。
あれば何もしませんが、ない場合はcontextのreport()でエラーを伝える、という感じです。

elementType()やgetPropValue()は、jsx-ast-utilsというutilプラグインに存在するものです。
https://github.com/evcohen/jsx-ast-utils
このライブラリはeslint-plugin-reactなどでも採用されているものですが、
元はeslint-plugin-jsx-a11yのutilだったようです。

コンポーネント名の不一致問題

eslint-plugin-jsx-a11yは現在、コンポーネントを対象としません。
よく挙げられるのがstyled-componentsの問題です。
例えば下記のStyledHtmlコンポーネントは、htmlではなくStyledHtmlという名前なので、条件分岐から外れます。

example.ts
// nodeはhtmlでなく、StyledHtmlである
const Foo: React.FC = () => {
  <StyledHtml></StyledHtml>
}

const StyledHtml = styled.html``;

export default Foo;

逆に言うと、StyledHtmlを認識させればエラーを出してくれます。
試しに、ルール上のhtmlStyledHtmlに置き換えます。

html-has-lang.js
// type && type !== 'html'を'StyledHtml'に変更
if (type && type !== 'StyledHtml') {
  return;
}

この状態でLintを実行すると、StyledHtmlはhtmlと同様のエラーを出します。

shell
error  <html> elements must have the lang prop

対象がHoCかどうかは関係なく、nodeの名前自体をチェックの対象としています。
例えば下記のように、HtmlからStyledHtmlを作成した場合でも、ESLintのルールはStyledHtmlを検知するので、その名前でのチェックが入ります。

src/components/Foo.tsx
// divとしてルールをチェックする
const Html: React.FC = () => {
  return <div></div>
}

const StyledHtml = styled(Html)``;

// StyledHtmlとしてルールをチェックする
const Foo: React.FC = () => {
  return <StyledHtml></StyledHtml>;
};

styled-componentsではasを使って、代替要素やコンポーネントを指定できますが、
これを使う場合、値はpropsに入るので、getPropValue()で確認する必要があります。
node.nameの他に、propsの値を見て回避させるなどの対処法になってしまうのかと思います。

src/components/Foo.tsx
// typeはDivとして解釈される。
// as属性でH2を確認できる
const Foo: React.FC = () => {
  const Div = styled.div``;
  const H2 = styled.h2``;
  return <Div as={H2}></Div>
}

このように、できなくはないのですが対応が面倒です。

マッパーの作成案

問題を解決するには、どのコンポーネントがどの要素名にあたるのかを調べなければなりません。
力技ですが、一つの案として、対応するコンポーネントのマッピングというのがあります。

ESLintのconfig(eslintrc.json)では、ルールごとに設定を追加することができます。
そこで、下記のようにcomponentsを追加してみます。

eslintrc.json
"jsx-a11y/html-has-lang": ["error", { "components": [ "StyledHtml" ] }]

すると、create関数内で、context.optionsから取得することができます。
このcomponentsの配列を、チェック対象の配列に加えるというのが一つの方法です。

html-has-lang.js
console.log(context.options);

// [ { components: [ 'StyledHtml' ] } ]

実は、同様の処理が一部のルールにあります。
下記は、heading属性にコンテンツを入れるべきというheading-has-contentの処理です。
あらかじめcontext.options[0].componentsを取得し、ルールの対象かどうかをチェックするためのtypeCheck配列に入れています。

heading-has-content.js
const options = context.options[0] || {};
const componentOptions = options.components || [];
const typeCheck = headings.concat(componentOptions);
const nodeType = elementType(node);

// Only check 'h*' elements and custom types.
if (typeCheck.indexOf(nodeType) === -1) {
  return;
}

実際に試してみます。
optionのcomponentsにStyledH2要素を加えます。

eslintrc.json
"jsx-a11y/heading-has-content": [
  "error", { "components": ["StyledH2"] }
]

コンポーネントです。
StyledH2要素がチェックの対象となるので、これはheading要素と同様のエラーを出します。

src/components/Foo.tsx
// StyledH2がcomponentsオプションに含まれるので、チェック対象となる
const Foo: React.FC = () => {
  const StyledH2 = styled.h2``;
  return <StyledH2></StyledH2>;
};

ただ、すべてのルールに同じ処理があるわけではありません。
そして、ルールごとにコンポーネントを設定しなければならないので、この方法は辛いです。

現在、この対処法については、Globalなマッパーを置く案が出ています。
コンポーネント、propsそれぞれに、対応する値をマッピングすることができるというものです。
しかし、音沙汰がありません。
https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/174

また、ルール名をワイルドカードで一括指定する案が出ていました。
これは、コンセンサスが得られずcloseしてます。
https://github.com/eslint/eslint/issues/9938

これらのグローバルな設定を、どの層で取り入れるのかもハッキリしないように思えます。
heading-has-contentのように、既にある処理を他のルールに書き足していくのか、jsx-ast-utilsで一旦変換を通すのか…。

まだ仕様が固まっておらず、実装もまちまちなので、何か浮かぶまで様子見な感じです。

終わり

何も解決しないまま終わりますが、 eslint-plugin-jsx-a11yの挙動と、現状の問題を把握することができました。
今回挙げたこと以外でも、運用上のネックとなっている記述が多く、PRチャンスがそれなりにあります。

ちなみにASTは、AST explorerで確認できます。
実際に試してみると、JSX上でルールを作ることの難しさを実感できます。

無事に転職できたら続きます。