a11y_agent 0.0.5.pre.alpha.1 → 0.0.5.pre.alpha.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/LICENSE +661 -0
- data/lib/a11y_agent/version.rb +1 -1
- data/lib/agents/a11y_agent.rb +160 -0
- data/lib/bin/axe.ts +19 -0
- data/lib/generators/fix_a11y_generator.rb +35 -0
- data/lib/generators/hydrate_document_generator.rb +27 -0
- data/lib/tasks/release.rake +6 -2
- data/package.json +8 -3
- data/yarn.lock +845 -187
- metadata +14 -11
- data/eslint.config.mjs +0 -23
- data/lib/a11y_agent.rb +0 -119
- data/lib/fix_a11y_generator.rb +0 -32
data/lib/a11y_agent/version.rb
CHANGED
@@ -0,0 +1,160 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dotenv/load'
|
4
|
+
require 'diffy'
|
5
|
+
require 'fileutils'
|
6
|
+
require 'json'
|
7
|
+
require 'open3'
|
8
|
+
require 'rainbow/refinement'
|
9
|
+
require 'sublayer'
|
10
|
+
require 'tty-prompt'
|
11
|
+
require_relative '../generators/fix_a11y_generator'
|
12
|
+
require_relative '../generators/hydrate_document_generator'
|
13
|
+
|
14
|
+
Diffy::Diff.default_format = :color
|
15
|
+
|
16
|
+
# Sublayer.configuration.ai_provider = Sublayer::Providers::OpenAI
|
17
|
+
# Sublayer.configuration.ai_model = 'gpt-4o-mini'
|
18
|
+
|
19
|
+
# Sublayer.configuration.ai_provider = Sublayer::Providers::Gemini
|
20
|
+
# Sublayer.configuration.ai_model = "gemini-1.5-flash-latest"
|
21
|
+
|
22
|
+
Sublayer.configuration.ai_provider = Sublayer::Providers::Claude
|
23
|
+
Sublayer.configuration.ai_model = 'claude-3-haiku-20240307'
|
24
|
+
|
25
|
+
CHOICES = [
|
26
|
+
{ key: 'y', name: 'approve and continue', value: :yes },
|
27
|
+
{ key: 'n', name: 'skip this change', value: :no },
|
28
|
+
{ key: 'r', name: 'retry with optional instructions', value: :retry },
|
29
|
+
{ key: 'q', name: 'quit; stop making changes', value: :quit }
|
30
|
+
].freeze
|
31
|
+
|
32
|
+
module Sublayer
|
33
|
+
module Agents
|
34
|
+
class A11yAgent < Base
|
35
|
+
using Rainbow
|
36
|
+
|
37
|
+
def initialize(file:)
|
38
|
+
@accessibility_issues = []
|
39
|
+
@issue_types = []
|
40
|
+
@file = file
|
41
|
+
@file_contents = File.read(@file)
|
42
|
+
@prompt = TTY::Prompt.new
|
43
|
+
end
|
44
|
+
|
45
|
+
trigger_on_files_changed do
|
46
|
+
['./trigger.txt']
|
47
|
+
end
|
48
|
+
|
49
|
+
check_status do
|
50
|
+
load_issues unless run_axe.empty?
|
51
|
+
end
|
52
|
+
|
53
|
+
goal_condition do
|
54
|
+
puts "🤷 No accessibility issues found in #{@file}" if @accessibility_issues.empty?
|
55
|
+
exit 0 if @accessibility_issues.empty?
|
56
|
+
end
|
57
|
+
|
58
|
+
step do
|
59
|
+
@accessibility_issues.each { |issue| fix_issue_and_save(issue:) }
|
60
|
+
exit 0
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def run_axe(file: @file)
|
66
|
+
stdout, _stderr, _status = Open3.capture3("ts-node lib/bin/axe.ts #{file}")
|
67
|
+
JSON.parse(stdout)
|
68
|
+
end
|
69
|
+
|
70
|
+
def load_issues
|
71
|
+
Tempfile.create(['', File.extname(@file)]) do |tempfile|
|
72
|
+
tempfile.write(hydrated_file)
|
73
|
+
tempfile.rewind
|
74
|
+
|
75
|
+
@accessibility_issues = run_axe(file: tempfile.path).map do |issue|
|
76
|
+
%w[id impact tags helpUrl].each { |key| issue.delete(key) }
|
77
|
+
issue
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
puts "🚨 Found #{@accessibility_issues.length} accessibility issues" unless @accessibility_issues.empty?
|
82
|
+
end
|
83
|
+
|
84
|
+
def hydrated_file
|
85
|
+
puts "Loading fake data into #{@file}"
|
86
|
+
hydrated = HydrateDocumentGenerator.new(contents: @file_contents, extension: File.extname(@file)).generate
|
87
|
+
hydrated << "\n" until hydrated.end_with?("\n")
|
88
|
+
|
89
|
+
print_diff(contents: @file_contents, fixed: hydrated, message: '📊 Changes made:')
|
90
|
+
hydration_approved = @prompt.yes? 'Continue with updates?'
|
91
|
+
hydrated = @file_contents unless hydration_approved
|
92
|
+
hydrated
|
93
|
+
end
|
94
|
+
|
95
|
+
def fix_issue_and_save(issue:)
|
96
|
+
updated_contents = File.read(@file)
|
97
|
+
|
98
|
+
issue['nodes'].each do |node|
|
99
|
+
user_input = nil
|
100
|
+
fixed = nil
|
101
|
+
additional_prompt = nil
|
102
|
+
summary = node['failureSummary']
|
103
|
+
node_issue = [summary, issue['help'], node['html']].join("\n\n")
|
104
|
+
|
105
|
+
puts "🔍 #{issue['help']}"
|
106
|
+
attempt = @prompt.yes? "Attempt to fix these issues in #{@file}?"
|
107
|
+
next unless attempt
|
108
|
+
|
109
|
+
until %i[yes no].include?(user_input)
|
110
|
+
puts '🔧 Attempting a fix...'
|
111
|
+
result = FixA11yGenerator.new(contents: updated_contents, issue: node_issue, extension: File.extname(@file),
|
112
|
+
additional_prompt:).generate
|
113
|
+
result << "\n" unless result.end_with?("\n")
|
114
|
+
|
115
|
+
print_chunks(contents: updated_contents, fixed: result)
|
116
|
+
|
117
|
+
user_input = @prompt.expand('Approve changes?', CHOICES)
|
118
|
+
|
119
|
+
case user_input
|
120
|
+
when :yes
|
121
|
+
fixed = result
|
122
|
+
when :no
|
123
|
+
fixed = updated_contents
|
124
|
+
when :retry
|
125
|
+
additional_prompt = @prompt.ask('Additional instructions:')
|
126
|
+
fixed = nil
|
127
|
+
when :quit
|
128
|
+
puts 'Quitting...'
|
129
|
+
exit 0
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
puts '📝 Saving changes...'
|
134
|
+
File.write(@file, fixed) if fixed
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def print_diff(contents:, fixed:, message: '')
|
139
|
+
puts message
|
140
|
+
puts Diffy::Diff.new(contents, fixed)
|
141
|
+
end
|
142
|
+
|
143
|
+
def print_chunks(contents:, fixed:)
|
144
|
+
Diffy::Diff.new(contents, fixed).each_chunk do |chunk|
|
145
|
+
case chunk
|
146
|
+
when /^\+/
|
147
|
+
print chunk.to_s.green
|
148
|
+
when /^-/
|
149
|
+
print chunk.to_s.red
|
150
|
+
else
|
151
|
+
lines = chunk.to_s.split("\n")
|
152
|
+
puts lines[0..2].join("\n")
|
153
|
+
puts '...'
|
154
|
+
puts lines[-3..].join("\n") if lines.length > 5
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
data/lib/bin/axe.ts
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
import { readFileSync } from "node:fs";
|
4
|
+
import axe from "axe-core"
|
5
|
+
import { JSDOM } from "jsdom";
|
6
|
+
|
7
|
+
const markup = readFileSync(process.argv[2], "utf8");
|
8
|
+
const { window: { document } } = new JSDOM(markup);
|
9
|
+
|
10
|
+
const config = {
|
11
|
+
rules: {
|
12
|
+
'color-contrast': { enabled: false },
|
13
|
+
'region': { enabled: false },
|
14
|
+
}
|
15
|
+
};
|
16
|
+
|
17
|
+
axe.run(document.body, config).then((results) => {
|
18
|
+
console.log(JSON.stringify(results.violations));
|
19
|
+
});
|
@@ -0,0 +1,35 @@
|
|
1
|
+
class FixA11yGenerator < Sublayer::Generators::Base
|
2
|
+
llm_output_adapter type: :single_string,
|
3
|
+
name: 'fix_template_content_based_on_a11y_issue',
|
4
|
+
description: 'Given a web document template and an accessibility issue, generate a new file with the issue fixed.'
|
5
|
+
|
6
|
+
def initialize(contents:, issue:, extension: '', additional_prompt: nil)
|
7
|
+
@extension = extension
|
8
|
+
@contents = contents
|
9
|
+
@issue = issue
|
10
|
+
@additional_prompt = additional_prompt
|
11
|
+
end
|
12
|
+
|
13
|
+
def generate
|
14
|
+
super
|
15
|
+
end
|
16
|
+
|
17
|
+
def prompt
|
18
|
+
<<~PROMPT
|
19
|
+
Given the following #{@extension} template contents and an
|
20
|
+
accessibility issue, generate a new #{@extension} file with the
|
21
|
+
issue fixed, leaving the rest of the contents unchanged.
|
22
|
+
|
23
|
+
#{@extension} code:
|
24
|
+
#{@contents}
|
25
|
+
|
26
|
+
Accessibility issue:
|
27
|
+
#{@issue}
|
28
|
+
|
29
|
+
Additional user instructions (if any):
|
30
|
+
#{@additional_prompt || 'None'}
|
31
|
+
|
32
|
+
Return the contents with the issue fixed.
|
33
|
+
PROMPT
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class HydrateDocumentGenerator < Sublayer::Generators::Base
|
2
|
+
llm_output_adapter type: :single_string,
|
3
|
+
name: 'hydrate_web_document_with_fake_variables',
|
4
|
+
description: 'Given a web document template, generate a new file with fake data interpolated into the template.'
|
5
|
+
|
6
|
+
def initialize(contents:, extension: '')
|
7
|
+
@contents = contents
|
8
|
+
@extension = extension
|
9
|
+
end
|
10
|
+
|
11
|
+
def generate
|
12
|
+
super
|
13
|
+
end
|
14
|
+
|
15
|
+
def prompt
|
16
|
+
<<~PROMPT
|
17
|
+
Given the following #{@extension} template contents, generate a new file by
|
18
|
+
replacing any undefined variables with fake data. Only replace undefined variables,
|
19
|
+
do not generate new markup.
|
20
|
+
|
21
|
+
#{@extension} code:
|
22
|
+
#{@contents}
|
23
|
+
|
24
|
+
Return the contents with the fake data interpolated.
|
25
|
+
PROMPT
|
26
|
+
end
|
27
|
+
end
|
data/lib/tasks/release.rake
CHANGED
@@ -54,8 +54,12 @@ namespace :release do
|
|
54
54
|
# Extract all files to libexec, which is a common Homebrew practice for third-party tools
|
55
55
|
libexec.install Dir["*"]
|
56
56
|
|
57
|
-
|
58
|
-
|
57
|
+
system "bundle", "install", "--without", "development"
|
58
|
+
system "gem", "build", "#{spec.name}.gemspec"
|
59
|
+
system "gem", "install", "--ignore-dependencies", "#{spec.name}-#{version}.gem"
|
60
|
+
|
61
|
+
bin.install libexec/"exe/#{spec.name}"
|
62
|
+
bin.env_script_all_files(libexec/"exe", GEM_HOME: ENV.fetch("GEM_HOME", nil))
|
59
63
|
end
|
60
64
|
|
61
65
|
test do
|
data/package.json
CHANGED
@@ -1,11 +1,16 @@
|
|
1
1
|
{
|
2
2
|
"devDependencies": {
|
3
|
-
"@
|
4
|
-
"eslint": "^
|
3
|
+
"@types/jsdom": "^21.1.7",
|
4
|
+
"eslint": "^8.57.0",
|
5
|
+
"eslint-plugin-import": "^2.29.1",
|
5
6
|
"eslint-plugin-jsx-a11y": "^6.9.0",
|
6
|
-
"eslint-plugin-react": "^7.35.0",
|
7
7
|
"globals": "^15.8.0",
|
8
8
|
"typescript": "^5.5.4",
|
9
9
|
"typescript-eslint": "^7.17.0"
|
10
|
+
},
|
11
|
+
"dependencies": {
|
12
|
+
"axe-core": "^4.10.0",
|
13
|
+
"canvas": "^2.11.2",
|
14
|
+
"jsdom": "^24.1.1"
|
10
15
|
}
|
11
16
|
}
|