eslint-plugin-jsx-a11yを少し調べる
JavaScript, ReactESLint のプラグイン "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 を認識させればエラーを出してくれる。
試しに、ルール上の html
を StyledHtml
に置き換える。
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で確認できる。
転職したらやろうと思ってて、何もやってない…。