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.
@@ -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
  }