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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module A11yAgent
4
- VERSION = '0.0.5-alpha.1'
4
+ VERSION = '0.0.5-alpha.4'
5
5
  end
@@ -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
@@ -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
- bin.install libexec/"bin/a11y_agent"
58
- bin.env_script_all_files(libexec/"bin", GEM_HOME: ENV.fetch("GEM_HOME"))
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
- "@eslint/js": "^9.8.0",
4
- "eslint": "^9.8.0",
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
  }