a11y_agent 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -0,0 +1,3 @@
1
+ OPENAI_API_KEY=sk-proj-xxxxx
2
+ GEMINI_API_KEY=xxxxx
3
+ ANTHROPIC_API_KEY=sk-ant-xxxxx
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
+ ];
@@ -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;
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11yAgent
4
+ VERSION = '0.0.1'
5
+ end
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
@@ -0,0 +1,11 @@
1
+ {
2
+ "devDependencies": {
3
+ "@eslint/js": "^9.8.0",
4
+ "eslint": "^9.8.0",
5
+ "eslint-plugin-jsx-a11y": "^6.9.0",
6
+ "eslint-plugin-react": "^7.35.0",
7
+ "globals": "^15.8.0",
8
+ "typescript": "^5.5.4",
9
+ "typescript-eslint": "^7.17.0"
10
+ }
11
+ }
data/trigger.txt ADDED
File without changes