a11y_agent 0.0.5.pre.alpha.1 → 0.0.5.pre.alpha.4
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 +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
|
}
|