├── .eslintignore
├── .gitignore
├── jest.config.js
├── tsconfig.eslint.json
├── .prettierrc.js
├── tests
├── prompts.test.ts
└── menu.test.ts
├── src
├── puppeteer.ts
├── prompts.ts
├── index.ts
├── menu.ts
├── manualCheckObj.ts
└── dataParser.ts
├── LICENSE
├── .eslintrc.js
├── README.md
├── package.json
├── info.js
└── tsconfig.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | .eslintrc.js
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
3 | .DS_Store
4 | .vscode/
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'node',
4 | };
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules", "lib"]
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: true,
3 | trailingComma: 'all',
4 | arrowParens: 'avoid',
5 | singleQuote: true,
6 | printWidth: 100,
7 | tabWidth: 2,
8 | };
9 |
--------------------------------------------------------------------------------
/tests/prompts.test.ts:
--------------------------------------------------------------------------------
1 | import { prompts } from '../src/prompts';
2 | import { prompt } from 'inquirer';
3 |
4 | // prompts.askPath() tests
5 | xit('should ask user for a path', () => {
6 | const question = [
7 | {
8 | name: 'url',
9 | type: 'input',
10 | message: 'Input URL or path:',
11 | },
12 | ]
13 | expect(prompts.askPath()).toMatchObject(prompt(question));
14 | })
15 |
--------------------------------------------------------------------------------
/src/puppeteer.ts:
--------------------------------------------------------------------------------
1 | import puppeteer from 'puppeteer';
2 | import { AxePuppeteer } from 'axe-puppeteer';
3 |
4 | export const puppet = async (URL: string) => {
5 | const browser = await puppeteer.launch();
6 | const page = await browser.newPage();
7 | const inputtedUrl = `${URL}`;
8 | await page.setBypassCSP(true);
9 |
10 | await page.goto(inputtedUrl);
11 |
12 | const results = await new AxePuppeteer(page).analyze();
13 |
14 | await page.close();
15 | await browser.close();
16 | return results.violations;
17 | };
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Sully Sullivan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/prompts.ts:
--------------------------------------------------------------------------------
1 | import { prompt, Separator } from 'inquirer';
2 | import { menu } from './menu';
3 |
4 | interface Prompts {
5 | askPath(): Promise<{ url: string }>;
6 | askOptions(results: any, target?: string): Promise<{ res: string }>;
7 | askError(error: string): Promise<{ startOver: string }>;
8 | }
9 |
10 | export const prompts: Prompts = {
11 | askPath: () => {
12 | const questions = [
13 | {
14 | name: 'url',
15 | type: 'input',
16 | message: 'Input URL or path:',
17 | },
18 | ];
19 | return prompt(questions);
20 | },
21 |
22 | askOptions: (results, target) => {
23 | const paths = menu.askMenu(results, target);
24 | const questions = [
25 | {
26 | name: 'res',
27 | type: 'list',
28 | pageSize: 35,
29 | message: 'anything else?',
30 | choices: ['refresh', 'new url','quit', new Separator(), ...paths],
31 | },
32 | ];
33 | return prompt(questions);
34 | },
35 |
36 | askError: error => {
37 | const questions = [
38 | {
39 | name: 'startOver',
40 | type: 'list',
41 | message: `There was an error, do you want to try again? ${error}`,
42 | choices: ['search again', 'quit'],
43 | },
44 | ];
45 | return prompt(questions);
46 | },
47 | };
48 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | node: true,
4 | jest: true,
5 | },
6 | parser: '@typescript-eslint/parser',
7 | plugins: ['@typescript-eslint', 'prettier'],
8 | extends: [
9 | 'airbnb-base',
10 | 'eslint:recommended',
11 | 'plugin:@typescript-eslint/recommended',
12 | 'prettier',
13 | ],
14 | parserOptions: {
15 | project: './tsconfig.eslint.json',
16 | },
17 | settings: {
18 | 'import/resolver': {
19 | node: {
20 | extensions: ['.ts'],
21 | },
22 | },
23 | },
24 | rules: {
25 | 'import/extensions': 0,
26 | 'object-curly-spacing': ['warn', 'always'],
27 | '@typescript-eslint/indent': 0,
28 | 'no-unused-vars': 0,
29 | '@typescript-eslint/no-unused-vars': [
30 | 'warn',
31 | {
32 | vars: 'all',
33 | args: 'all',
34 | argsIgnorePattern: '^_',
35 | },
36 | ],
37 | 'no-debugger': 0, // 1
38 | 'no-console': 0, // ['error', { allow: ['warn', 'error'] }],
39 | 'no-shadow': 0,
40 | 'no-param-reassign': 0,
41 | 'no-return-assign': 0,
42 | 'no-constant-condition': ['error', { checkLoops: false }],
43 | 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }],
44 | 'import/no-default-export': 1,
45 | 'import/prefer-default-export': 0,
46 | '@typescript-eslint/explicit-module-boundary-types': 0,
47 | },
48 | };
49 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # equa11y
2 |
3 | ### Welcome to equa11y, an easy to use command-line tool for accessiblity testing.
4 |
5 | #### To start:
6 |
7 | ```
8 | npx equa11y
9 | ```
10 |
11 | For testing websites you must place 'http://' before you type in a url.
12 | Local files can also be tested! If your site requires a server, spin it up and navigate to your locahost.
13 | If not, you can simply type in an absolute path!
14 |
15 | - mac ex: 'file:///Users/\*\*/mySite.html'
16 | - win ex: 'C:\Users\*\*\mySite.html'
17 |
18 | To quit at any point use 'ctrl + c'.
19 |
20 | ### Contribute
21 |
22 | ##### Find us on [GitHub](https://github.com/oslabs-beta/equa11y)
23 |
24 | ### More Information
25 |
26 | ##### Find us on the [web](http://equa11y-website.herokuapp.com/)
27 |
28 | ##### Read about us on [Medium](https://medium.com/better-programming/introducing-equa11y-a-command-line-testing-tool-for-web-accessibility-aa29205eed55)
29 |
30 | ### Authors
31 |
32 | ##### Heather Friedman | [LinkedIn](https://www.linkedin.com/in/hgfriedman/) | [GitHub](https://github.com/heatherfriedman)
33 |
34 | ##### Tjolanda Sullivan | [LinkedIn](https://www.linkedin.com/in/willhack/) | [GitHub](https://github.com/willhack)
35 |
36 | ##### Taylor Riley Du | [LinkedIn](https://www.linkedin.com/in/taylorsriley/) | [GitHub](https://github.com/taylordu)
37 |
38 | ##### Will Hack | [LinkedIn](https://www.linkedin.com/in/willhack/) | [GitHub](https://github.com/willhack)
39 |
40 | ### License
41 |
42 | ##### This project is licensed under MIT License.
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "equa11y",
3 | "version": "4.2.1",
4 | "description": "",
5 | "main": "index.ts",
6 | "scripts": {
7 | "test": "jest",
8 | "tsc": "tsc",
9 | "dev": "ts-node-dev --respawn ./src/index.ts",
10 | "prod": "tsc && node ./build/server/server.js",
11 | "lint": "eslint src/** --color"
12 | },
13 | "bin": {
14 | "equa11y": "./build/index.js"
15 | },
16 | "files": [
17 | "build"
18 | ],
19 | "repository": {
20 | "type": "git",
21 | "url": "git+https://github.com/oslabs-beta/Equa11y.git"
22 | },
23 | "keywords": [
24 | "accessibility",
25 | "cli",
26 | "testing",
27 | "test",
28 | "accessible",
29 | "wcag",
30 | "a11y",
31 | "axe-core",
32 | "TDD",
33 | "BDD"
34 | ],
35 | "author": "Heather Friedman, Tjolanda Sullivan, Taylor Riley Du, Will Hack",
36 | "license": "MIT",
37 | "bugs": {
38 | "url": "https://github.com/oslabs-beta/Equa11y/issues"
39 | },
40 | "homepage": "http://www.equa11y.com",
41 | "devDependencies": {
42 | "@types/axe-core": "^2.0.7",
43 | "@types/chalk": "^2.2.0",
44 | "@types/clear": "^0.1.0",
45 | "@types/cli-color": "^2.0.0",
46 | "@types/clui": "^0.3.0",
47 | "@types/express": "^4.17.8",
48 | "@types/figlet": "^1.2.0",
49 | "@types/inquirer": "^7.3.1",
50 | "@types/jest": "^26.0.14",
51 | "@types/node": "^14.6.4",
52 | "@types/puppeteer": "^3.0.1",
53 | "@types/strip-color": "^0.1.0",
54 | "@types/through": "0.0.30",
55 | "@typescript-eslint/eslint-plugin": "^4.0.1",
56 | "@typescript-eslint/parser": "^4.0.1",
57 | "eslint": "^7.8.1",
58 | "eslint-config-airbnb-base": "^14.2.0",
59 | "eslint-config-prettier": "^6.11.0",
60 | "eslint-plugin-import": "^2.22.0",
61 | "eslint-plugin-prettier": "^3.1.4",
62 | "jest": "^26.4.2",
63 | "prettier": "^2.1.1",
64 | "ts-jest": "^26.4.0",
65 | "ts-node-dev": "^1.0.0-pre.62",
66 | "typescript": "^4.0.2",
67 | "typescript-eslint": "0.0.1-alpha.0"
68 | },
69 | "dependencies": {
70 | "axe-core": "^4.0.1",
71 | "axe-puppeteer": "^1.1.0",
72 | "cfonts": "^2.8.6",
73 | "chalk": "^4.1.0",
74 | "clear": "^0.1.0",
75 | "clui": "^0.3.6",
76 | "inquirer": "^7.3.3",
77 | "open": "^7.2.1",
78 | "puppeteer": "^5.2.1",
79 | "strip-color": "^0.1.0"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/tests/menu.test.ts:
--------------------------------------------------------------------------------
1 | import { menu } from '../src/menu';
2 | import chalk from 'chalk';
3 |
4 | // menu.processLevel() tests
5 | describe('process objects', () => {
6 | describe('given only a level name', () => {
7 | const topObj = menu.processLevel('top level');
8 |
9 | it('should have a levelName', () => {
10 | expect(topObj.levelName).toBe('top level');
11 | })
12 | it('should have a subLevel array', () => {
13 | expect(topObj.subLevel).toStrictEqual([]);
14 | })
15 | it('should have an opened boolean', () => {
16 | expect(topObj.opened).toBe(false);
17 | })
18 | it('should have a nested number', () => {
19 | expect(topObj.nested).toBe(0);
20 | })
21 | })
22 |
23 | describe('given multiple arguments', () => {
24 | const bottomObj = menu.processLevel('bottom level', [], true, 2);
25 |
26 | it('should have a levelName', () => {
27 | expect(bottomObj.levelName).toBe('bottom level');
28 | })
29 | it('should have a subLevel', () => {
30 | expect(bottomObj.subLevel).toStrictEqual([]);
31 | })
32 | it('should have an opened boolean', () => {
33 | expect(bottomObj.opened).toBe(true);
34 | })
35 | it('should have a nested number', () => {
36 | expect(bottomObj.nested).toBe(2);
37 | })
38 | })
39 | })
40 |
41 | // menu.Stringify() tests
42 | describe('stringify processed objets', () => {
43 | describe('top level objects', () => {
44 | const processedTop = {
45 | levelName: 'critical',
46 | subLevel: [],
47 | opened: false,
48 | nested: 0,
49 | }
50 | it('should stringify top level closed', () => {
51 | expect(menu.stringify(processedTop)).toBe(`⇒ ${chalk.hex('#FF0000')('critical')} (0) issues type(s), (0) total error location(s)`)
52 | })
53 | it('should stringify top level opened', () => {
54 | processedTop.opened = true;
55 | expect(menu.stringify(processedTop)).toBe(`⇓ ${chalk.hex('#FF0000')('critical')} (0) issues type(s), (0) total error location(s)`)
56 | })
57 | })
58 |
59 | describe('middle level objects', () => {
60 | const processedMiddle = {
61 | levelName: 'contrast is too low',
62 | subLevel: [[{ html: '' }]],
63 | opened: false,
64 | nested: 1,
65 | }
66 | it('should stringify middle level closed', () => {
67 | expect(menu.stringify(processedMiddle)).toBe(`${chalk.blueBright(' ⇒ contrast is too low - ENTER for (1) total error location(s)')}`)
68 | })
69 | })
70 | describe('bottom level objects', () => {
71 | const processedBottom = {
72 | levelName: '',
73 | subLevel: [],
74 | opened: false,
75 | nested: 2,
76 | }
77 | it('should stringify bottom level', () => {
78 | expect(menu.stringify(processedBottom)).toBe(' ');
79 | })
80 | })
81 | })
82 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import chalk from 'chalk';
3 | import clear from 'clear';
4 | import CFonts from 'cfonts';
5 | import CLI from 'clui';
6 | import open from 'open';
7 | import stripColor from 'strip-color';
8 | import { prompts } from './prompts';
9 | import { puppet } from './puppeteer';
10 | import { dataParser, ParsedData } from './dataParser';
11 |
12 | interface Program {
13 | start(path?: string): Promise;
14 | loop(parsed: ParsedData, path: string, targetLevel?: string): Promise;
15 | }
16 |
17 | const { Spinner } = CLI;
18 | const spinner = new Spinner('Loading, please wait!');
19 |
20 | export const program: Program = {
21 | start: async (path) => {
22 | // Heading creation
23 | clear();
24 | CFonts.say('equa11y', {
25 | font: 'simple3d',
26 | space: false,
27 | gradient: ['#ff3333', 'magenta', '#00bebe'],
28 | transitionGradient: true,
29 | });
30 | // Ask for URL/localpath
31 | try {
32 | // const inputURL = { url: 'http://codesmith.io' }; // optional hardcoding for dev
33 | const inputURL = (path) ? { url: path } : await prompts.askPath(); // real prompt for publishing
34 | spinner.start();
35 | const data = await puppet(inputURL.url);
36 | const parsed = dataParser(data);
37 | spinner.stop();
38 |
39 | // Ask user for next step
40 | await program.loop(parsed, inputURL.url);
41 | } catch (error) {
42 | spinner.stop();
43 |
44 | const errors = await prompts.askError(error);
45 | if (errors.startOver === 'quit') process.exit(0);
46 | else if (errors.startOver === 'search again') program.start();
47 | }
48 | },
49 |
50 | loop: async (parsed, path, targetLevel) => {
51 | // Reset the display
52 | clear();
53 | CFonts.say('equa11y', {
54 | font: 'simple3d',
55 | space: false,
56 | gradient: ['#ff3333', 'magenta', '#00bebe'],
57 | transitionGradient: true,
58 | });
59 | console.log(chalk.bold('Testing:'), path);
60 |
61 | const options = await prompts.askOptions(parsed, targetLevel);
62 | // remove color for processing
63 | options.res = stripColor(options.res);
64 | if (options.res === 'quit') process.exit(0);
65 | // check to see if selection is a link
66 | else if (options.res.trim().slice(0, 4) === 'http') {
67 | open(options.res.trim());
68 | program.loop(parsed, path);
69 | } else if (options.res === 'new url') program.start();
70 | else if (options.res === 'refresh') program.start(path);
71 | // check if nested
72 | else if (options.res[0] === ' ') {
73 | // grabs string between arrow and '(n) issues types: TBD total sub issues'
74 | const id = options.res.trim().split(' ').slice(1, -7).join(' ');
75 | program.loop(parsed, path, id);
76 | }
77 | // Parse leading arrow
78 | else {
79 | const arrow = options.res[0];
80 | if (arrow === '⇒') {
81 | const targetLevel = options.res.split(' ')[1];
82 | program.loop(parsed, path, targetLevel);
83 | } else {
84 | program.loop(parsed, path);
85 | }
86 | }
87 | },
88 | };
89 |
90 | program.start();
91 |
--------------------------------------------------------------------------------
/info.js:
--------------------------------------------------------------------------------
1 | // results.violations[0] this one is the first obj in the array
2 | [
3 | {
4 | id: 'aria-required-attr',
5 | impact: 'critical',
6 | tags: ['cat.aria', 'wcag2a', 'wcag412'],
7 | description: 'Ensures elements with ARIA roles have all required ARIA attributes',
8 | help: 'Required ARIA attributes must be provided',
9 | helpUrl:
10 | 'https://dequeuniversity.com/rules/axe/3.5/aria-required-attr?application=axe-puppeteer',
11 | nodes: [
12 | {
13 | any: [Array],
14 | all: [],
15 | none: [],
16 | impact: 'critical',
17 | html:
18 | '',
19 | target: [Array],
20 | failureSummary:
21 | 'Fix any of the following:\n' + ' Required ARIA attribute not present: aria-expanded',
22 | },
23 | ],
24 | },
25 | ][
26 | // results.violations[0].nodes
27 | {
28 | any: [
29 | {
30 | id: 'aria-required-attr',
31 | data: ['aria-expanded'],
32 | relatedNodes: [],
33 | impact: 'critical',
34 | message: 'Required ARIA attribute not present: aria-expanded',
35 | },
36 | ],
37 | all: [],
38 | none: [],
39 | impact: 'critical',
40 | html:
41 | '',
42 | target: ['.gLFyf'],
43 | failureSummary:
44 | 'Fix any of the following:\n' + ' Required ARIA attribute not present: aria-expanded',
45 | }
46 | ][
47 | // results.violations[0].nodes[0].any
48 | {
49 | id: 'aria-required-attr',
50 | data: ['aria-expanded'],
51 | relatedNodes: [],
52 | impact: 'critical',
53 | message: 'Required ARIA attribute not present: aria-expanded',
54 | }
55 | ];
56 |
57 | // google.com
58 | [
59 | {
60 | id: 'aria-required-attr',
61 | impact: 'critical',
62 | tags: [ 'cat.aria', 'wcag2a', 'wcag412' ],
63 | description: 'Ensures elements with ARIA roles have all required ARIA attributes',
64 | help: 'Required ARIA attributes must be provided',
65 | helpUrl: 'https://dequeuniversity.com/rules/axe/3.5/aria-required-attr?application=axe-puppeteer',
66 | nodes: [ [Object] ]
67 | },
68 | {
69 | id: 'bypass',
70 | impact: 'serious',
71 | tags: [
72 | 'cat.keyboard',
73 | 'wcag2a',
74 | 'wcag241',
75 | 'section508',
76 | 'section508.22.o'
77 | ],
78 | description: 'Ensures each page has at least one mechanism for a user to bypass navigation and jump straight to the content',
79 | help: 'Page must have means to bypass repeated blocks',
80 | helpUrl: 'https://dequeuniversity.com/rules/axe/3.5/bypass?application=axe-puppeteer',
81 | nodes: [ [Object] ]
82 | },
83 | {
84 | id: 'landmark-one-main',
85 | impact: 'moderate',
86 | tags: [ 'cat.semantics', 'best-practice' ],
87 | description: 'Ensures the document has a main landmark',
88 | help: 'Document must have one main landmark',
89 | helpUrl: 'https://dequeuniversity.com/rules/axe/3.5/landmark-one-main?application=axe-puppeteer',
90 | nodes: [ [Object] ]
91 | },
92 | {
93 | id: 'page-has-heading-one',
94 | impact: 'moderate',
95 | tags: [ 'cat.semantics', 'best-practice' ],
96 | description: 'Ensure that the page, or at least one of its frames contains a level-one heading',
97 | help: 'Page must contain a level-one heading',
98 | helpUrl: 'https://dequeuniversity.com/rules/axe/3.5/page-has-heading-one?application=axe-puppeteer',
99 | nodes: [ [Object] ]
100 | },
101 | {
102 | id: 'region',
103 | impact: 'moderate',
104 | tags: [ 'cat.keyboard', 'best-practice' ],
105 | description: 'Ensures all page content is contained by landmarks',
106 | help: 'All page content must be contained by landmarks',
107 | helpUrl: 'https://dequeuniversity.com/rules/axe/3.5/region?application=axe-puppeteer',
108 | nodes: [
109 | [Object], [Object],
110 | [Object], [Object],
111 | [Object], [Object],
112 | [Object], [Object]
113 | ]
114 | }
115 | ]
116 |
--------------------------------------------------------------------------------
/src/menu.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import { ParsedData, IssueInfo } from './dataParser';
3 |
4 | export interface MenuContents {
5 | levelName: string;
6 | opened: boolean;
7 | nested: number;
8 | subLevel: any[];
9 | }
10 |
11 | interface Dropdown {
12 | askMenu(results: ParsedData, level?: string): string[];
13 | processLevel(
14 | levelName: string,
15 | subLevel?: IssueInfo[],
16 | opened?: boolean,
17 | nested?: number,
18 | ): MenuContents;
19 | stringify(levelObj: MenuContents): string;
20 | }
21 |
22 | export const menu: Dropdown = {
23 | askMenu: (results, targetLevel) => {
24 | // process sorted issues into menu form
25 | const processed: MenuContents[] = [];
26 | (Object.keys(results) as Array).forEach(issueLevel => {
27 | if (targetLevel) {
28 | if (targetLevel === 'manual') targetLevel = 'manualTests';
29 | // Bottom level conditional: targetLevel is given...
30 | if (targetLevel!.split(' ').length > 1) {
31 | // ....and it is more than 1 word long
32 | let targetLevelFlag = false;
33 | // Check the middle level to see if it was the targetLevel passed in
34 | (results[issueLevel] as Array).forEach((issue: any) => {
35 | if (issue.title === targetLevel) targetLevelFlag = true;
36 | });
37 | if (targetLevelFlag) {
38 | // If so, process all three levels!
39 | processed.push(menu.processLevel(issueLevel, results[issueLevel], true)); // Top level: open
40 | (results[issueLevel] as Array).forEach((issue: any) => {
41 | if (issue.title === targetLevel) {
42 | processed.push(menu.processLevel(issue.title, issue.specificIssues, true, 1)); // Middle level: opened, indent
43 | // wcag url for guidance
44 | processed.push(menu.processLevel(issue.urlToWCAG, [], false, 2));
45 | processed.push(
46 | menu.processLevel(issue.specificIssues[0].recommendation, [], false, 2),
47 | );
48 | issue.specificIssues.forEach((specificIssue: any) => {
49 | // Bottom levels: (closed), double indent
50 | processed.push(menu.processLevel(specificIssue.html, [], false, 2));
51 | });
52 | } else {
53 | // Middle level: closed, indent
54 | processed.push(menu.processLevel(issue.title, issue.specificIssues, false, 1));
55 | }
56 | });
57 | } else {
58 | // Top level: closed
59 | processed.push(menu.processLevel(issueLevel, results[issueLevel]));
60 | } // otherwise targetLevel given but only 1 words so looking for middle menu level
61 | } else if (targetLevel === issueLevel) {
62 | processed.push(menu.processLevel(issueLevel, results[issueLevel], true));
63 | (results[issueLevel] as Array).forEach((issue: any) => {
64 | processed.push(menu.processLevel(issue.title, issue.specificIssues, false, 1));
65 | });
66 | } else {
67 | processed.push(menu.processLevel(issueLevel, results[issueLevel]));
68 | }
69 | } else {
70 | // Base case: nothing selected, top level only!
71 | processed.push(menu.processLevel(issueLevel, results[issueLevel]));
72 | }
73 | });
74 | // Stringify the array of processed menu options
75 | const options = processed.map((option: MenuContents) => menu.stringify(option));
76 | return options;
77 | },
78 |
79 | processLevel: (levelName, subLevel = [], opened = false, nested = 0) => {
80 | return {
81 | levelName,
82 | opened,
83 | nested,
84 | subLevel,
85 | };
86 | },
87 |
88 | stringify: (levelObj) => {
89 | const { opened, nested, subLevel } = levelObj;
90 | let { levelName } = levelObj; // let for chalifying later on
91 | const arrows = ['⇒', '⇓', '⇨', '⇩'];
92 | let option = '';
93 | option += ' '.repeat(nested);
94 |
95 | if (nested > 1) {
96 | option += levelName;
97 | } else if (nested === 1) {
98 | // middle level
99 | option += opened ? arrows[1] : arrows[0];
100 | if (subLevel[0].html !== '') {
101 | option += ` ${levelName} - ENTER for (${subLevel.length}) total error location(s)`;
102 | } else {
103 | option += ` ${levelName} - ENTER for URL to more information`;
104 | }
105 | option = chalk.blueBright(option);
106 | } else {
107 | // top level
108 | option += opened ? arrows[1] : arrows[0];
109 | let subIssues = 0;
110 | if (subLevel.length) {
111 | subLevel.forEach((issue: any) => {
112 | if (issue.specificIssues.length) subIssues += issue.specificIssues.length;
113 | });
114 | } // build and chalkify based on severity
115 | if (levelName !== 'manualTests') {
116 | if (levelName === 'critical') levelName = chalk.hex('#FF0000')(levelName);
117 | else if (levelName === 'serious') levelName = chalk.hex('#E66000')(levelName);
118 | else if (levelName === 'moderate') levelName = chalk.hex('#CC0077')(levelName);
119 | else levelName = chalk.magentaBright(levelName);
120 | option += ` ${levelName} (${subLevel.length}) issues type(s), (${subIssues}) total error location(s)`;
121 | } else {
122 | option += ` ${chalk.hex('#00A5A5')('manual tests')} ENTER for more information regarding manual testing`;
123 | }
124 | }
125 | return option;
126 | }
127 | };
128 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */
4 |
5 | /* Basic Options */
6 | // "incremental": true, /* Enable incremental compilation */
7 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
9 | "lib": [
10 | "ES2015",
11 | "dom"
12 | ] /* Specify library files to be included in the compilation. */,
13 | // "allowJs": true, /* Allow javascript files to be compiled. */
14 | // "checkJs": true, /* Report errors in .js files. */
15 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
16 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
17 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
18 | // "sourceMap": true, /* Generates corresponding '.map' file. */
19 | // "outFile": "./", /* Concatenate and emit output to single file. */
20 | "outDir": "build" /* Redirect output structure to the directory. */,
21 | "rootDir": "src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
22 | // "composite": true, /* Enable project compilation */
23 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
24 | // "removeComments": true, /* Do not emit comments to output. */
25 | // "noEmit": true, /* Do not emit outputs. */
26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
27 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
29 |
30 | /* Strict Type-Checking Options */
31 | "strict": true /* Enable all strict type-checking options. */,
32 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
33 | // "strictNullChecks": true, /* Enable strict null checks. */
34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
35 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
36 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
37 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
38 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
39 |
40 | /* Additional Checks */
41 | // "noUnusedLocals": true, /* Report errors on unused locals. */
42 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
43 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
44 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
45 |
46 | /* Module Resolution Options */
47 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
48 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
51 | // "typeRoots": [], /* List of folders to include type definitions from. */
52 | "types": [
53 | "node",
54 | "jest"
55 | ] /* Type declaration files to be included in compilation. */,
56 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
57 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
58 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
59 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
60 |
61 | /* Source Map Options */
62 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
63 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
64 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
65 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
66 |
67 | /* Experimental Options */
68 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
69 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
70 |
71 | /* Advanced Options */
72 | //"skipLibCheck": true /* Skip type checking of declaration files. */,
73 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
74 | },
75 | "include": ["src"],
76 | "exclude": ["./tests/*.test.ts"]
77 | }
78 |
--------------------------------------------------------------------------------
/src/manualCheckObj.ts:
--------------------------------------------------------------------------------
1 | import { IssueInfo } from "./dataParser"
2 |
3 | // Manual checks abridged wcag obj
4 |
5 |
6 | // interface ManualCheckInfo {
7 | // num: string;
8 | // level: string;
9 | // title: string;
10 | // urlToWCAGToWCAG: string;
11 | // }
12 |
13 |
14 | export const manualCheckObj: IssueInfo[] = [
15 | {
16 | // "num": "1.2.1",
17 | // "level": "A",
18 | "title": "For prerecorded audio-only and prerecorded video-only media, the following are true, except when the audio or video is a media alternative for text and is clearly labeled as such:",
19 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/audio-only-and-video-only-prerecorded",
20 | "specificIssues": [{ "recommendation": "", "html": "" }],
21 | },
22 | {
23 | // "num": "1.2.2",
24 | // "level": "A",
25 | "title": "Captions are provided for all prerecorded audio content in synchronized media, except when the media is a media alternative for text and is clearly labeled as such.",
26 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/captions-prerecorded",
27 | "specificIssues": [{ "recommendation": "", "html": "" }],
28 | },
29 | {
30 | // "num": "1.2.3",
31 | // "level": "A",
32 | "title": "An alternative for time-based media or audio description of the prerecorded video content is provided for synchronized media, except when the media is a media alternative for text and is clearly labeled as such.",
33 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/audio-description-or-media-alternative-prerecorded",
34 | "specificIssues": [{ "recommendation": "", "html": "" }],
35 | },
36 | {
37 | // "num": "1.3.2",
38 | // "level": "A",
39 | "title": "When the sequence in which content is presented affects its meaning, a correct reading sequence can be programmatically determined.",
40 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/meaningful-sequence",
41 | "specificIssues": [{ "recommendation": "", "html": "" }],
42 | },
43 | {
44 | // "num": "1.3.3",
45 | // "level": "A",
46 | "title": "Instructions provided for understanding and operating content do not rely solely on sensory characteristics of components such as shape, color, size, visual location, orientation, or sound.",
47 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/sensory-characteristics",
48 | "specificIssues": [{ "recommendation": "", "html": "" }],
49 | },
50 | {
51 | // "num": "2.1.2",
52 | // "level": "A",
53 | "title": "If keyboard focus can be moved to a component of the page using a keyboard interface, then focus can be moved away from that component using only a keyboard interface, and, if it requires more than unmodified arrow or tab keys or other standard exit methods, the user is advised of the method for moving focus away.",
54 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/no-keyboard-trap",
55 | "specificIssues": [{ "recommendation": "", "html": "" }],
56 | },
57 | {
58 | // "num": "2.1.4",
59 | // "level": "A",
60 | "title": "If a keyboard shortcut is implemented in content using only letter (including upper- and lower-case letters), punctuation, number, or symbol characters, then at least one of the following is true:",
61 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/character-key-shortcuts",
62 | "specificIssues": [{ "recommendation": "", "html": "" }],
63 | },
64 | {
65 | // "num": "2.3.1",
66 | // "level": "A",
67 | "title": "Web pages do not contain anything that flashes more than three times in any one second period, or the flash is below the general flash and red flash thresholds.",
68 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/three-flashes-or-below-threshold",
69 | "specificIssues": [{ "recommendation": "", "html": "" }],
70 | },
71 | {
72 | // "num": "2.4.3",
73 | // "level": "A",
74 | "title": "If a Web page can be navigated sequentially and the navigation sequences affect meaning or operation, focusable components receive focus in an order that preserves meaning and operability.",
75 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/focus-order",
76 | "specificIssues": [{ "recommendation": "", "html": "" }],
77 | },
78 | {
79 | // "num": "2.5.1",
80 | // "level": "A",
81 | "title": "All functionality that uses multipoint or path-based gestures for operation can be operated with a single pointer without a path-based gesture, unless a multipoint or path-based gesture is essential.",
82 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/pointer-gestures",
83 | "specificIssues": [{ "recommendation": "", "html": "" }],
84 | },
85 | {
86 | // "num": "2.5.2",
87 | // "level": "A",
88 | "title": "For functionality that can be operated using a single pointer, at least one of the following is true:",
89 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/pointer-cancellation",
90 | "specificIssues": [{ "recommendation": "", "html": "" }],
91 | },
92 | {
93 | // "num": "2.5.4",
94 | // "level": "A",
95 | "title": "Functionality that can be operated by device motion or user motion can also be operated by user interface components and responding to the motion can be disabled to prevent accidental actuation, except when:",
96 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/motion-actuation",
97 | "specificIssues": [{ "recommendation": "", "html": "" }],
98 | },
99 | {
100 | // "num": "3.2.1",
101 | // "level": "A",
102 | "title": "When any user interface component receives focus, it does not initiate a change of context.",
103 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/on-focus",
104 | "specificIssues": [{ "recommendation": "", "html": "" }],
105 | },
106 | {
107 | // "num": "3.2.2",
108 | // "level": "A",
109 | "title": "Changing the setting of any user interface component does not automatically cause a change of context unless the user has been advised of the behavior before using the component.",
110 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/on-input",
111 | "specificIssues": [{ "recommendation": "", "html": "" }],
112 | },
113 | {
114 | // "num": "3.3.1",
115 | // "level": "A",
116 | "title": "If an input error is automatically detected, the item that is in error is identified and the error is described to the user in text.",
117 | "urlToWCAG": "https://www.w3.org/WAI/WCAG21/Understanding/error-identification",
118 | "specificIssues": [{ "recommendation": "", "html": "" }],
119 | }
120 | ]
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/src/dataParser.ts:
--------------------------------------------------------------------------------
1 | import { Result, NodeResult } from 'axe-core';
2 | import { wcag } from './wcag';
3 | import { manualCheckObj } from './manualCheckObj';
4 |
5 | export interface SpecificIssue {
6 | recommendation?: string;
7 | html?: string;
8 | }
9 |
10 | export interface IssueInfo {
11 | dequeId?: string;
12 | wcagCriteria?: string;
13 | urlToWCAG?: string;
14 | title?: string;
15 | specificIssues?: SpecificIssue[];
16 | impact?: Result['impact'];
17 | }
18 |
19 | export interface ParsedData {
20 | minor?: IssueInfo[];
21 | moderate?: IssueInfo[];
22 | serious?: IssueInfo[];
23 | critical?: IssueInfo[];
24 | manualTests?: IssueInfo[];
25 | nonEssential?: IssueInfo[];
26 | }
27 |
28 | export const dataParser = (dataToBeParsed: Result[]): ParsedData => {
29 | // sort issues into common occurances i.e. { critical: [resultItem1, resultItem2], severe: [resultItem3]}
30 | const data = dataToBeParsed.reduce((parsedData: ParsedData, curIssue: Result) => {
31 | const specificIssuePopulator = (node: NodeResult): SpecificIssue => {
32 | const parsedSpecificIssue: SpecificIssue = {};
33 | parsedSpecificIssue.recommendation = node.failureSummary;
34 | parsedSpecificIssue.html = node.html;
35 | return parsedSpecificIssue;
36 | };
37 |
38 | // default values for no given wcag information
39 | let wcagURLInfo = curIssue.helpUrl;
40 | let wcagCriteriaInfo = 'n/a';
41 | let foundFlag = false;
42 |
43 | // parse through wcag.ts object to see if we've added a dq_id
44 | const wcagConnector = () => {
45 | const { id } = curIssue;
46 | for (let i = 0; i < wcag.principles.length; i += 1) {
47 | for (let j = 0; j < wcag.principles[i].guidelines.length; j += 1) {
48 | for (let k = 0; k < wcag.principles[i].guidelines[j].successcriteria.length; k += 1) {
49 | const location = wcag.principles[i].guidelines[j].successcriteria[k];
50 | if (location.dq_id && location.dq_id.includes(id)) {
51 | foundFlag = true;
52 | wcagURLInfo = location.url;
53 | wcagCriteriaInfo = location.num;
54 | break;
55 | }
56 | }
57 | if (foundFlag) break;
58 | }
59 | if (foundFlag) break;
60 | }
61 | };
62 |
63 | const issuesPopulator = (): IssueInfo => {
64 | const parsedIssue: IssueInfo = {};
65 | wcagConnector();
66 | parsedIssue.dequeId = curIssue.id;
67 | parsedIssue.wcagCriteria = wcagCriteriaInfo; // wcag.principles[index].guidelines[0].successcriteria[0].num
68 | parsedIssue.urlToWCAG = wcagURLInfo; // wcag.principles[index].guidelines[0].successcriteria[0].url
69 | parsedIssue.title = curIssue.help;
70 | parsedIssue.specificIssues = curIssue.nodes.map(node => specificIssuePopulator(node));
71 | parsedIssue.impact = curIssue.impact;
72 | return parsedIssue;
73 | };
74 |
75 | const parsedIssue = issuesPopulator();
76 | if (curIssue.impact === null || curIssue.impact === undefined) {
77 | if (parsedData.nonEssential) parsedData.nonEssential.push(parsedIssue);
78 | else parsedData.nonEssential = [parsedIssue];
79 | } else if (parsedData[curIssue.impact]) {
80 | parsedData[curIssue.impact]?.push(parsedIssue);
81 | } else {
82 | parsedData[curIssue.impact] = [parsedIssue];
83 | }
84 | return parsedData;
85 | }, {});
86 | // add manual tests to object
87 | data.manualTests = manualCheckObj;
88 | return data;
89 | };
90 |
91 |
92 | // Returned parsed example
93 | // export const demoParsed = {
94 | // critical: [
95 | // {
96 | // dequeId: 'aria-required-attr',
97 | // wcagCriteria: 'n/a',
98 | // urlToWCAG: 'https://dequeuniversity.com/rules/axe/3.5/aria-required-attr?application=axe-puppeteer',
99 | // title: 'Required ARIA attributes must be provided',
100 | // specificIssues: [Array],
101 | // impact: 'critical'
102 | // }
103 | // ],
104 | // serious: [
105 | // {
106 | // dequeId: 'bypass',
107 | // wcagCriteria: 'n/a',
108 | // urlToWCAG: 'https://dequeuniversity.com/rules/axe/3.5/bypass?application=axe-puppeteer',
109 | // title: 'Page must have means to bypass repeated blocks',
110 | // specificIssues: [Array],
111 | // impact: 'serious'
112 | // }
113 | // ],
114 | // moderate: [
115 | // {
116 | // dequeId: 'landmark-one-main',
117 | // wcagCriteria: 'n/a',
118 | // urlToWCAG: 'https://dequeuniversity.com/rules/axe/3.5/landmark-one-main?application=axe-puppeteer',
119 | // title: 'Document must have one main landmark',
120 | // specificIssues: [Array],
121 | // impact: 'moderate'
122 | // },
123 |
124 | // ],
125 | // manualTests: [
126 | // {
127 | // title: 'For prerecorded audio-only and prerecorded video-only media, the following are true, except when the audio or video is a media alternative for text and is clearly labeled as such:',
128 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/audio-only-and-video-only-prerecorded'
129 | // },
130 | // {
131 | // title: 'Captions are provided for all prerecorded audio content in synchronized media, except when the media is a media alternative for text and is clearly labeled as such.',
132 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/captions-prerecorded'
133 | // },
134 | // {
135 | // title: 'An alternative for time-based media or audio description of the prerecorded video content is provided for synchronized media, except when the media is a media alternative for text and is clearly labeled as such.',
136 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/audio-description-or-media-alternative-prerecorded'
137 | // },
138 | // {
139 | // title: 'When the sequence in which content is presented affects its meaning, a correct reading sequence can be programmatically determined.',
140 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/meaningful-sequence'
141 | // },
142 | // {
143 | // title: 'Instructions provided for understanding and operating content do not rely solely on sensory characteristics of components such as shape, color, size, visual location, orientation, or sound.',
144 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/sensory-characteristics'
145 | // },
146 | // {
147 | // title: 'If keyboard focus can be moved to a component of the page using a keyboard interface, then focus can be moved away from that component using only a keyboard interface, and, if it requires more than unmodified arrow or tab keys or other standard exit methods, the user is advised of the method for moving focus away.',
148 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/no-keyboard-trap'
149 | // },
150 | // {
151 | // title: 'If a keyboard shortcut is implemented in content using only letter (including upper- and lower-case letters), punctuation, number, or symbol characters, then at least one of the following is true:',
152 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/character-key-shortcuts'
153 | // },
154 | // {
155 | // title: 'Web pages do not contain anything that flashes more than three times in any one second period, or the flash is below the general flash and red flash thresholds.',
156 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/three-flashes-or-below-threshold'
157 | // },
158 | // {
159 | // title: 'If a Web page can be navigated sequentially and the navigation sequences affect meaning or operation, focusable components receive focus in an order that preserves meaning and operability.',
160 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/focus-order'
161 | // },
162 | // {
163 | // title: 'All functionality that uses multipoint or path-based gestures for operation can be operated with a single pointer without a path-based gesture, unless a multipoint or path-based gesture is essential.',
164 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/pointer-gestures'
165 | // },
166 | // {
167 | // title: 'For functionality that can be operated using a single pointer, at least one of the following is true:',
168 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/pointer-cancellation'
169 | // },
170 | // {
171 | // title: 'Functionality that can be operated by device motion or user motion can also be operated by user interface components and responding to the motion can be disabled to prevent accidental actuation, except when:',
172 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/motion-actuation'
173 | // },
174 | // {
175 | // title: 'When any user interface component receives focus, it does not initiate a change of context.',
176 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/on-focus'
177 | // },
178 | // {
179 | // title: 'Changing the setting of any user interface component does not automatically cause a change of context unless the user has been advised of the behavior before using the component.',
180 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/on-input'
181 | // },
182 | // {
183 | // title: 'If an input error is automatically detected, the item that is in error is identified and the error is described to the user in text.',
184 | // urlToWCAG: 'https://www.w3.org/WAI/WCAG21/Understanding/error-identification'
185 | // }
186 | // ]
187 | // }
188 |
--------------------------------------------------------------------------------