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

JavaScript, React

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の仕組み

実装がわかりやすいルールを見る。
下記は「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>
}

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

マッパーの作成案

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

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

eslintrc.json

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

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

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で確認できる。

転職したらやろうと思ってて、何もやってない…。