a11y_agent 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.env.sample +3 -0
- data/README.md +20 -0
- data/eslint.config.mjs +23 -0
- data/fixtures/sample.tsx +100 -0
- data/lib/a11y_agent/version.rb +5 -0
- data/lib/a11y_agent.rb +119 -0
- data/lib/fix_a11y_generator.rb +32 -0
- data/package.json +11 -0
- data/trigger.txt +0 -0
- data/yarn.lock +1863 -0
- metadata +126 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 94ca19fae4a088d592146b8df934aa4b4a2b21f60e9318198ba79c3b0bff769c
|
4
|
+
data.tar.gz: 39c4d8da1ece45a3a350efaf5efecd69ed3986e0da435a7d03f256186a7dd89d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 594a27d14711f26226a383aa74f45bc2bd58d880c700edc21fcd70efbd51204c0acf432366eb0ec884ae6505ea6fcd34729b6f5b862508b373c4977ca6213624
|
7
|
+
data.tar.gz: 8947f736049078fe40be73c49325f45a2ce185c337c04ba979af45ae90dee33998d36107a89c4f2d461962d4c5aca23f6e9f839a513ef9c25da8a0d4c520c8ee
|
data/.env.sample
ADDED
data/README.md
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# A11y Agent
|
2
|
+
|
3
|
+
A11y Agent is a multi-turn AI agent that supports developers in remediating React templates. It uses rules from `eslint-plugin-jsx-a11y` from a [Sublayer](https://github.com/sublayerapp/sublayer) agent to perform iterative fixes, with user input along the way.
|
4
|
+
|
5
|
+
A11y Agent is unique in the way it combines traditional programming, AI prompting, and human judgement to accelerate the accessibility remediation process.
|
6
|
+
|
7
|
+
## Usage
|
8
|
+
|
9
|
+
1. Clone the repo:
|
10
|
+
|
11
|
+
git clone git@github.com:AccessLint/a11y-agent.git
|
12
|
+
|
13
|
+
2. Run the CLI command and follow the prompts:
|
14
|
+
|
15
|
+
ruby a11y_agent.rb /path/to/file.[jsx|tsx]
|
16
|
+
|
17
|
+
## Demo
|
18
|
+
|
19
|
+
https://github.com/user-attachments/assets/757227ad-7f8f-4fa8-933f-daea335313c9
|
20
|
+
|
data/eslint.config.mjs
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
import globals from "globals";
|
2
|
+
import pluginJs from "@eslint/js";
|
3
|
+
import tseslint from "typescript-eslint";
|
4
|
+
import pluginReact from "eslint-plugin-react";
|
5
|
+
import jsxA11y from "eslint-plugin-jsx-a11y";
|
6
|
+
|
7
|
+
export default [
|
8
|
+
{
|
9
|
+
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
10
|
+
languageOptions: {
|
11
|
+
parserOptions: {
|
12
|
+
ecmaFeatures: {
|
13
|
+
jsx: true,
|
14
|
+
},
|
15
|
+
},
|
16
|
+
},
|
17
|
+
},
|
18
|
+
{ languageOptions: { globals: globals.browser } },
|
19
|
+
jsxA11y.flatConfigs.strict,
|
20
|
+
pluginJs.configs.recommended,
|
21
|
+
...tseslint.configs.recommended,
|
22
|
+
pluginReact.configs.flat.recommended,
|
23
|
+
];
|
data/fixtures/sample.tsx
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
import React from "react";
|
2
|
+
|
3
|
+
const AccessibilityViolations = () => {
|
4
|
+
return (
|
5
|
+
<div>
|
6
|
+
<html>
|
7
|
+
<head>
|
8
|
+
<title>Accessibility Violations</title>
|
9
|
+
</head>
|
10
|
+
<body>
|
11
|
+
<div>
|
12
|
+
<p>Here is an emoji not wrapped in a span: 😀</p>
|
13
|
+
|
14
|
+
<img src="image.jpg" alt="image" />
|
15
|
+
|
16
|
+
<a href="https://example.com">Click here</a>
|
17
|
+
|
18
|
+
<a href="#"></a>
|
19
|
+
|
20
|
+
<a href="invalid-url">Invalid Link</a>
|
21
|
+
|
22
|
+
<div aria-activedescendant="some-id"></div>
|
23
|
+
|
24
|
+
<div aria-bogus="true"></div>
|
25
|
+
|
26
|
+
<div aria-hidden="false"></div>
|
27
|
+
|
28
|
+
<div role="invalidRole"></div>
|
29
|
+
|
30
|
+
<input type="text" autoComplete="off" />
|
31
|
+
|
32
|
+
<div onClick={() => alert("Clicked!")}></div>
|
33
|
+
|
34
|
+
<input type="text" />
|
35
|
+
|
36
|
+
<h1></h1>
|
37
|
+
|
38
|
+
<iframe src="some-video.mp4"></iframe>
|
39
|
+
|
40
|
+
<img src="photo.jpg" alt="photo" />
|
41
|
+
|
42
|
+
<div onClick={() => alert("Clicked!")} tabIndex={-1}></div>
|
43
|
+
|
44
|
+
<label>Username</label>
|
45
|
+
<input type="text" id="username" />
|
46
|
+
|
47
|
+
<label htmlFor="username">Username</label>
|
48
|
+
<input type="text" id="username" />
|
49
|
+
|
50
|
+
<audio controls>
|
51
|
+
<source src="audio.mp3" type="audio/mp3" />
|
52
|
+
</audio>
|
53
|
+
|
54
|
+
<div onMouseOver={() => {}}></div>
|
55
|
+
|
56
|
+
<div accessKey="s"></div>
|
57
|
+
|
58
|
+
<div aria-hidden="true" tabIndex={0}></div>
|
59
|
+
|
60
|
+
<input type="text" autoFocus />
|
61
|
+
|
62
|
+
<marquee>Scrolling text</marquee>
|
63
|
+
|
64
|
+
<div role="button" onClick={() => {}}></div>
|
65
|
+
|
66
|
+
<div onClick={() => {}}></div>
|
67
|
+
|
68
|
+
<div role="link"></div>
|
69
|
+
|
70
|
+
<div tabIndex={1}></div>
|
71
|
+
|
72
|
+
<select onChange={() => {}}>
|
73
|
+
<option>Option 1</option>
|
74
|
+
<option>Option 2</option>
|
75
|
+
</select>
|
76
|
+
|
77
|
+
<div role="banner"></div>
|
78
|
+
|
79
|
+
<button role="button">Click me</button>
|
80
|
+
|
81
|
+
<table>
|
82
|
+
<tr>
|
83
|
+
<th>Header</th>
|
84
|
+
<th>Another Header</th>
|
85
|
+
</tr>
|
86
|
+
<tr>
|
87
|
+
<td>Data</td>
|
88
|
+
<td>More Data</td>
|
89
|
+
</tr>
|
90
|
+
</table>
|
91
|
+
|
92
|
+
<div tabIndex={5}></div>
|
93
|
+
</div>
|
94
|
+
</body>
|
95
|
+
</html>
|
96
|
+
</div>
|
97
|
+
);
|
98
|
+
};
|
99
|
+
|
100
|
+
export default AccessibilityViolations;
|
data/lib/a11y_agent.rb
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'diffy'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'json'
|
6
|
+
require 'open3'
|
7
|
+
require 'rainbow/refinement'
|
8
|
+
require 'sublayer'
|
9
|
+
require 'tty-prompt'
|
10
|
+
require_relative './fix_a11y_generator'
|
11
|
+
|
12
|
+
Diffy::Diff.default_format = :color
|
13
|
+
|
14
|
+
# Sublayer.configuration.ai_provider = Sublayer::Providers::OpenAi
|
15
|
+
# Sublayer.configuration.ai_model = "gpt-4o-mini"
|
16
|
+
|
17
|
+
# Sublayer.configuration.ai_provider = Sublayer::Providers::Gemini
|
18
|
+
# Sublayer.configuration.ai_model = "gemini-1.5-flash-latest"
|
19
|
+
|
20
|
+
Sublayer.configuration.ai_provider = Sublayer::Providers::Claude
|
21
|
+
Sublayer.configuration.ai_model = 'claude-3-haiku-20240307'
|
22
|
+
|
23
|
+
module Sublayer
|
24
|
+
module Agents
|
25
|
+
class A11yAgent < Base
|
26
|
+
using Rainbow
|
27
|
+
|
28
|
+
def initialize(file:)
|
29
|
+
@accessibility_issues = []
|
30
|
+
@issue_types = []
|
31
|
+
@file = file
|
32
|
+
@file_contents = File.read(@file)
|
33
|
+
end
|
34
|
+
|
35
|
+
trigger_on_files_changed do
|
36
|
+
['./trigger.txt']
|
37
|
+
end
|
38
|
+
|
39
|
+
check_status do
|
40
|
+
puts "🔍 Checking accessibility issues in #{@file}..."
|
41
|
+
stdout, _stderr, _status = Open3.capture3("eslint #{@file} --format stylish")
|
42
|
+
|
43
|
+
@accessibility_issues = stdout.split("\n")[2..]
|
44
|
+
@accessibility_issues = @accessibility_issues.map { |issue| issue.gsub(/\s+/, ' ').strip }
|
45
|
+
|
46
|
+
puts "🚨 Found #{@accessibility_issues.length} accessibility issues" unless @accessibility_issues.empty?
|
47
|
+
end
|
48
|
+
|
49
|
+
goal_condition do
|
50
|
+
puts '🎉 All accessibility issues have been fixed!' if @accessibility_issues.empty?
|
51
|
+
@accessibility_issues.empty?
|
52
|
+
end
|
53
|
+
|
54
|
+
step do
|
55
|
+
prompt = TTY::Prompt.new
|
56
|
+
|
57
|
+
@accessibility_issues.each do |issue|
|
58
|
+
contents = File.read(@file)
|
59
|
+
|
60
|
+
user_input = nil
|
61
|
+
fixed = nil
|
62
|
+
additional_prompt = nil
|
63
|
+
|
64
|
+
until %i[yes no].include?(user_input)
|
65
|
+
puts "🔧 Attempting a fix: #{issue}"
|
66
|
+
result = FixA11yGenerator.new(contents:, issue:,
|
67
|
+
additional_prompt:).generate
|
68
|
+
result << "\n" unless result.end_with?("\n")
|
69
|
+
|
70
|
+
Diffy::Diff.new(contents, result).each_chunk do |chunk|
|
71
|
+
case chunk
|
72
|
+
when /^\+/
|
73
|
+
print chunk.to_s.green
|
74
|
+
when /^-/
|
75
|
+
print chunk.to_s.red
|
76
|
+
else
|
77
|
+
lines = chunk.to_s.split("\n")
|
78
|
+
puts lines[0..2].join("\n")
|
79
|
+
puts '...'
|
80
|
+
puts lines[-3..].join("\n")
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
choices = [
|
85
|
+
{ key: 'y', name: 'approve and continue', value: :yes },
|
86
|
+
{ key: 'n', name: 'skip this change', value: :no },
|
87
|
+
{ key: 'r', name: 'retry with optional instructions', value: :retry },
|
88
|
+
{ key: 'q', name: 'quit; stop making changes', value: :quit }
|
89
|
+
]
|
90
|
+
|
91
|
+
user_input = prompt.expand('Approve changes?', choices)
|
92
|
+
|
93
|
+
case user_input
|
94
|
+
when :yes
|
95
|
+
fixed = result
|
96
|
+
when :no
|
97
|
+
fixed = contents
|
98
|
+
when :retry
|
99
|
+
additional_prompt = prompt.ask('Additional instructions:')
|
100
|
+
fixed = nil
|
101
|
+
when :quit
|
102
|
+
puts 'Quitting...'
|
103
|
+
exit
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
contents = fixed
|
108
|
+
|
109
|
+
puts 'Writing to file...'
|
110
|
+
File.write(@file, contents)
|
111
|
+
end
|
112
|
+
|
113
|
+
puts '🎉 Done!'
|
114
|
+
puts '✅ Complete diff:'
|
115
|
+
puts Diffy::Diff.new(@file_contents, File.read(@file))
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
class FixA11yGenerator < Sublayer::Generators::Base
|
2
|
+
llm_output_adapter type: :single_string,
|
3
|
+
name: "fix_accessibility_issue_based_on_a11y_issue",
|
4
|
+
description: "Given a JSX file and an accessibility issue, generate a new JSX file with the issue fixed."
|
5
|
+
|
6
|
+
def initialize(contents:, issue:, additional_prompt: nil)
|
7
|
+
@contents = contents
|
8
|
+
@issue = issue
|
9
|
+
@additional_prompt = additional_prompt
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate
|
13
|
+
super
|
14
|
+
end
|
15
|
+
|
16
|
+
def prompt
|
17
|
+
<<~PROMPT
|
18
|
+
Given the following JSX contents and an individual accessibility issue, generate a new JSX file with the individual issue fixed, leaving the rest of the contents unchanged.:
|
19
|
+
|
20
|
+
Code:
|
21
|
+
#{@contents}
|
22
|
+
|
23
|
+
Accessibility issue:
|
24
|
+
#{@issue}
|
25
|
+
|
26
|
+
Additional user instructions (if any):
|
27
|
+
#{@additional_prompt || "None"}
|
28
|
+
|
29
|
+
Return the JSX contents with the issue fixed.
|
30
|
+
PROMPT
|
31
|
+
end
|
32
|
+
end
|
data/package.json
ADDED
data/trigger.txt
ADDED
File without changes
|