Setting the file. One moment.
Scan A11Y Eslint · Wix App · wix/skills · Skills Docs
ContentsBack to the top of the page
Number 1.79
Position 79 of 81
Type JavaScript
Size 9 KB
Lines 295 scripts/ scan-a11y-eslint.cjs
JavaScript · 295 lines · 9 KB
ROOT
=
process.
cwd
();
11 const LOCAL_REQUIRE = createRequire (__filename);
12 const ROOT_REQUIRE = createRequire (path. join ( ROOT , 'package.json' ));
13 const { ESLint } = loadModule ( 'eslint' );
14 const jsxA11y = loadModule ( 'eslint-plugin-jsx-a11y' );
15 const tsParser = loadModule ( '@typescript-eslint/parser' );
16
17 const RULE_CONFIG = {
18 'jsx-a11y/alt-text' : 'error' ,
19 'jsx-a11y/anchor-has-content' : 'error' ,
20 'jsx-a11y/anchor-is-valid' : 'error' ,
21 'jsx-a11y/aria-activedescendant-has-tabindex' : 'error' ,
22 'jsx-a11y/aria-props' : 'error' ,
23 'jsx-a11y/aria-proptypes' : 'error' ,
24 'jsx-a11y/aria-role' : 'error' ,
25 'jsx-a11y/aria-unsupported-elements' : 'error' ,
26 'jsx-a11y/click-events-have-key-events' : 'error' ,
27 'jsx-a11y/heading-has-content' : 'error' ,
28 'jsx-a11y/iframe-has-title' : 'error' ,
29 'jsx-a11y/img-redundant-alt' : 'error' ,
30 'jsx-a11y/interactive-supports-focus' : 'error' ,
31 'jsx-a11y/label-has-associated-control' : 'error' ,
32 'jsx-a11y/media-has-caption' : 'error' ,
33 'jsx-a11y/mouse-events-have-key-events' : 'error' ,
34 'jsx-a11y/no-access-key' : 'error' ,
35 'jsx-a11y/no-aria-hidden-on-focusable' : 'error' ,
36 'jsx-a11y/no-autofocus' : 'error' ,
37 'jsx-a11y/no-distracting-elements' : 'error' ,
38 'jsx-a11y/no-interactive-element-to-noninteractive-role' : [ 'error' , { canvas: [ 'img' ] }],
39 'jsx-a11y/no-noninteractive-element-interactions' : 'error' ,
40 'jsx-a11y/no-noninteractive-element-to-interactive-role' : 'error' ,
41 // A tabpanel with no focusable content takes tabIndex=0 (ARIA Authoring Practices).
42 'jsx-a11y/no-noninteractive-tabindex' : [ 'error' , { roles: [ 'tabpanel' ] }],
43 'jsx-a11y/no-redundant-roles' : 'error' ,
44 'jsx-a11y/no-static-element-interactions' : 'error' ,
45 'jsx-a11y/prefer-tag-over-role' : 'error' ,
46 'jsx-a11y/role-has-required-aria-props' : 'error' ,
47 'jsx-a11y/role-supports-aria-props' : 'error' ,
48 'jsx-a11y/scope' : 'error' ,
49 'jsx-a11y/tabindex-no-positive' : 'error' ,
50 };
51
52 function toRelative ( filePath ) {
53 return path. relative ( ROOT , filePath) || filePath;
54 }
55
56 /** DOM handler → SDK prop it forwards (FUNCTION-HANDLERS.md). */
57 const SDK_HANDLER_PROPS = {
58 onClick: 'onClick' ,
59 onDoubleClick: 'onDblClick' ,
60 onMouseEnter: 'onMouseIn' ,
61 onMouseLeave: 'onMouseOut' ,
62 };
63 const isSdkRoot = ( tag ) => / \b id= \{ \s * (?:props \. ) ? id \s * \} / . test (tag);
64
65 /** Every handler on the tag forwards its SDK prop, `onClick={onClick}` or `onMouseEnter={props.onMouseIn}`; anything else makes the root a control. */
66 function forwardsOnlySdkHandlers ( tag ) {
67 const handlers = [ ... tag. matchAll ( / \b (on [A-Z]\w * )= \{ ( [ ^ }] * ) \} / g )];
68 return (
69 handlers. length > 0 &&
70 handlers. every (([, name , value ]) => {
71 const sdkProp = SDK_HANDLER_PROPS [name];
72 return Boolean (sdkProp) && value. trim (). replace ( / ^ props \. / , '' ) === sdkProp;
73 })
74 );
75 }
76
77 /** Roles whose native element cannot express a styled component. */
78 const ROLES_WITHOUT_NATIVE_TAG = new Set ([ 'img' , 'presentation' , 'none' , 'group' , 'status' ]);
79
80 /**
81 * `role="img"` or `role={cond ? 'img' : undefined}`: every string literal in the role
82 * attribute must be an exempt role, so `role={cond ? 'button' : 'img'}` stays flagged.
83 */
84 function roleHasNoNativeTag ( tag ) {
85 const attr = tag. match ( / \b role=(?:" [ ^ "] * " | ' [ ^ '] * ' | \{ [ ^ }] * \} )/ );
86 if ( ! attr) return false ;
87 const literals = [ ... attr[ 0 ]. slice ( 5 ). matchAll ( / ["'] ( [ ^ "'] * ) ["'] / g )]. map (( m ) => m[ 1 ]);
88 return literals. length > 0 && literals. every (( role ) => ROLES_WITHOUT_NATIVE_TAG . has (role));
89 }
90
91 /**
92 * Editor React Component patterns that jsx-a11y reads as defects. A rule in
93 * `rules` is skipped when the opening tag of the reported element matches `tag`.
94 */
95 const EXEMPTIONS = [
96 {
97 // SDK handlers (`onClick`, `onMouseIn`, ...) are forwarded on the root
98 // element, which carries `id={id}`; the root itself is not the control.
99 rules: new Set ([
100 'jsx-a11y/click-events-have-key-events' ,
101 'jsx-a11y/mouse-events-have-key-events' ,
102 'jsx-a11y/no-noninteractive-element-interactions' ,
103 'jsx-a11y/no-static-element-interactions' ,
104 ]),
105 tag : ( tag ) => isSdkRoot (tag) && forwardsOnlySdkHandlers (tag),
106 },
107 {
108 // Roles whose native element cannot express a styled component: graphics
109 // (svg, canvas, star ratings), decorative wrappers, widget groups, live
110 // regions. axe still requires their names (`role-img-alt`).
111 rules: new Set ([ 'jsx-a11y/prefer-tag-over-role' ]),
112 tag: roleHasNoNativeTag,
113 },
114 ];
115
116 /** Text of the opening tag that contains the reported range. */
117 function openingTag ( source , msg ) {
118 const lines = source. split ( ' \n ' );
119 const offset =
120 lines. slice ( 0 , msg.line - 1 ). reduce (( n , line ) => n + line. length + 1 , 0 ) + msg.column - 1 ;
121 const start = source. lastIndexOf ( '<' , offset);
122 if (start === - 1 ) return '' ;
123 // The tag ends at the first `>` outside braces, so `=>` inside handlers does not count.
124 let depth = 0 ;
125 for ( let i = start; i < source. length ; i ++ ) {
126 if (source[i] === '{' ) depth ++ ;
127 else if (source[i] === '}' ) depth -- ;
128 else if (source[i] === '>' && depth === 0 ) return source. slice (start, i);
129 }
130 return '' ;
131 }
132
133 const isExempt = ( source , msg ) =>
134 EXEMPTIONS . some (( x ) => x.rules. has (msg.ruleId) && x. tag ( openingTag (source, msg)));
135
136 function severityLabel ( severity ) {
137 if (severity === 2 ) return 'error' ;
138 if (severity === 1 ) return 'warning' ;
139 return 'off' ;
140 }
141
142 function loadModule ( name ) {
143 try {
144 return LOCAL_REQUIRE (name);
145 } catch (localError) {
146 try {
147 return ROOT_REQUIRE (name);
148 } catch (rootError) {
149 throw new Error (
150 `Missing dependency "${ name }". Local resolution failed: ${ localError . message }. Root resolution failed: ${ rootError . message }` ,
151 );
152 }
153 }
154 }
155
156 function createEslint () {
157 try {
158 return new ESLint ({
159 overrideConfigFile: true ,
160 overrideConfig: [
161 {
162 files: [ '**/*.{tsx,jsx,ts,js}' ],
163 languageOptions: {
164 parser: tsParser,
165 parserOptions: {
166 ecmaFeatures: { jsx: true },
167 ecmaVersion: 2022 ,
168 sourceType: 'module' ,
169 },
170 },
171 plugins: {
172 'jsx-a11y' : jsxA11y,
173 },
174 rules: RULE_CONFIG ,
175 },
176 ],
177 });
178 } catch (flatConfigError) {
179 try {
180 return new ESLint ({
181 useEslintrc: false ,
182 overrideConfig: {
183 parser: '@typescript-eslint/parser' ,
184 plugins: [ 'jsx-a11y' ],
185 parserOptions: {
186 ecmaFeatures: { jsx: true },
187 ecmaVersion: 2022 ,
188 sourceType: 'module' ,
189 },
190 rules: RULE_CONFIG ,
191 },
192 extensions: [ '.tsx' , '.jsx' , '.ts' , '.js' ],
193 });
194 } catch (legacyConfigError) {
195 throw new Error (
196 `Failed to initialize ESLint. Flat config error: ${ flatConfigError . message }. Legacy config error: ${ legacyConfigError . message }` ,
197 );
198 }
199 }
200 }
201
202 /**
203 * Lint the given files with the jsx-a11y rule set.
204 * Returns the same report shape the CLI prints.
205 */
206 async function scan ( files ) {
207 const absoluteFiles = files. map (( f ) => path. resolve (f));
208
209 const eslint = createEslint ();
210
211 const results = await eslint. lintFiles (absoluteFiles);
212
213 const findings = [];
214 const parseErrors = [];
215
216 for ( const result of results) {
217 const relFile = toRelative (result.filePath);
218 const source = () => result.source || fs. readFileSync (result.filePath, 'utf8' );
219
220 for ( const msg of result.messages) {
221 if (msg.fatal) {
222 parseErrors. push ({
223 file: relFile,
224 line: msg.line,
225 column: msg.column,
226 message: msg.message,
227 });
228 continue ;
229 }
230
231 if ( ! msg.ruleId || ! msg.ruleId. startsWith ( 'jsx-a11y/' )) continue ;
232 if ( isExempt ( source (), msg)) continue ;
233
234 findings. push ({
235 file: relFile,
236 line: msg.line,
237 column: msg.column,
238 endLine: msg.endLine ?? null ,
239 endColumn: msg.endColumn ?? null ,
240 rule: msg.ruleId,
241 severity: severityLabel (msg.severity),
242 message: msg.message,
243 });
244 }
245 }
246
247 const ruleBreakdown = {};
248 for ( const f of findings) {
249 ruleBreakdown[f.rule] = (ruleBreakdown[f.rule] || 0 ) + 1 ;
250 }
251
252 return {
253 meta: {
254 filesScanned: files. length ,
255 engine: 'eslint + eslint-plugin-jsx-a11y' ,
256 rulesEnabled: Object. keys ( RULE_CONFIG ). length ,
257 parseErrors,
258 },
259 findings,
260 summary: {
261 totalFindings: findings. length ,
262 filesWithFindings: new Set (findings. map (( f ) => f.file)).size,
263 cleanFiles: files. length - new Set (findings. map (( f ) => f.file)).size,
264 ruleBreakdown,
265 },
266 };
267 }
268
269 async function main () {
270 const files = process.argv. slice ( 2 );
271 if (files. length === 0 ) {
272 console. log (
273 JSON . stringify (
274 {
275 error: 'No files specified.' ,
276 usage: 'node <SKILL_ROOT>/scripts/scan-a11y-eslint.cjs <file1> [file2] ...' ,
277 },
278 null ,
279 2 ,
280 ),
281 );
282 process. exit ( 1 );
283 }
284
285 console. log ( JSON . stringify ( await scan (files), null , 2 ));
286 }
287
288 module . exports = { scan, RULE_CONFIG };
289
290 if (require.main === module ) {
291 main (). catch (( err ) => {
292 console. error ( JSON . stringify ({ error: err.message }, null , 2 ));
293 process. exit ( 1 );
294 });
295 }