rails_accessibility_testing 1.5.8 → 1.6.0

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.
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'nokogiri'
5
+ require_relative 'accessibility_helper'
6
+ require_relative 'erb_extractor'
7
+
8
+ # Builds a composition graph of a Rails page (layout + view + partials)
9
+ # This allows us to check heading hierarchy across the complete composed page
10
+ # rather than individual files
11
+ module RailsAccessibilityTesting
12
+ # Builds the composition of a Rails page by tracing:
13
+ # - Layout file (application.html.erb)
14
+ # - View file (yield content)
15
+ # - All partials rendered (recursively)
16
+ #
17
+ # @api private
18
+ class ViewCompositionBuilder
19
+ include AccessibilityHelper::PartialDetection
20
+
21
+ attr_reader :view_file, :layout_file, :all_files
22
+
23
+ def initialize(view_file)
24
+ @view_file = view_file
25
+ @layout_file = nil
26
+ @all_files = []
27
+ @visited_files = Set.new
28
+ end
29
+
30
+ # Build the complete composition
31
+ # @return [Array<String>] Array of all file paths in the composition
32
+ def build
33
+ # Resolve view file path (handle relative/absolute)
34
+ view_file_path = normalize_path(@view_file)
35
+ return [] unless view_file_path && File.exist?(view_file_path)
36
+
37
+ @all_files = []
38
+ @visited_files = Set.new
39
+
40
+ # Find layout file
41
+ @layout_file = find_layout_file_for_view(view_file_path)
42
+ if @layout_file
43
+ layout_path = normalize_path(@layout_file)
44
+ @all_files << layout_path if layout_path && File.exist?(layout_path)
45
+ end
46
+
47
+ # Recursively find all partials (handles nested partials, collections, etc.)
48
+ # Note: find_all_partials_recursive adds files to @all_files and @visited_files
49
+ # We call it BEFORE manually adding to ensure it processes the file
50
+ find_all_partials_recursive(view_file_path)
51
+ if @layout_file
52
+ layout_path = normalize_path(@layout_file)
53
+ find_all_partials_recursive(layout_path) if layout_path
54
+ end
55
+
56
+ # Ensure view file is in @all_files (it should be added by find_all_partials_recursive)
57
+ @all_files << view_file_path unless @all_files.include?(view_file_path)
58
+
59
+ @all_files.uniq
60
+ end
61
+
62
+ # Normalize file path to consistent format (absolute if possible)
63
+ def normalize_path(file_path)
64
+ return nil unless file_path
65
+
66
+ # Try with Rails.root first (most reliable for Rails apps)
67
+ if defined?(Rails) && Rails.root
68
+ rails_path = Rails.root.join(file_path)
69
+ return rails_path.to_s if File.exist?(rails_path)
70
+ end
71
+
72
+ # If file exists as-is (relative or absolute), return it
73
+ return file_path if File.exist?(file_path)
74
+
75
+ # Try making it absolute if it's relative
76
+ if !file_path.start_with?('/')
77
+ expanded = File.expand_path(file_path)
78
+ return expanded if File.exist?(expanded)
79
+ end
80
+
81
+ # Return original if nothing works (will be checked later)
82
+ file_path
83
+ end
84
+
85
+ # Get all headings from the complete composition
86
+ # @return [Array<Hash>] Array of heading info: { level: 1-6, text: String, file: String, line: Integer }
87
+ def all_headings
88
+ headings = []
89
+
90
+ @all_files.each do |file|
91
+ # Handle both relative and absolute paths
92
+ file_path = if File.exist?(file)
93
+ file
94
+ elsif defined?(Rails) && Rails.root
95
+ rails_path = Rails.root.join(file)
96
+ rails_path.exist? ? rails_path.to_s : nil
97
+ else
98
+ nil
99
+ end
100
+
101
+ next unless file_path && File.exist?(file_path)
102
+
103
+ content = File.read(file_path)
104
+ html_content = ErbExtractor.extract_html(content)
105
+ doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
106
+
107
+ doc.css('h1, h2, h3, h4, h5, h6').each do |heading|
108
+ level = heading.name[1].to_i
109
+ text = heading.text.strip
110
+ line = find_line_number(content, heading)
111
+
112
+ headings << {
113
+ level: level,
114
+ text: text,
115
+ file: file_path, # Use resolved path
116
+ line: line
117
+ }
118
+ end
119
+ end
120
+
121
+ # Sort by file order (layout first, then view, then partials)
122
+ # This preserves the DOM order
123
+ headings.sort_by do |h|
124
+ file_index = @all_files.index(h[:file]) || 999
125
+ [file_index, h[:line]]
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ # Find layout file for a view
132
+ # Handles:
133
+ # - Explicit layout declaration in view: layout 'custom_layout'
134
+ # - Controller-level layout (via ApplicationController)
135
+ # - Default application layout
136
+ def find_layout_file_for_view(view_file)
137
+ return nil unless view_file && File.exist?(view_file)
138
+
139
+ # Check for layout declaration in view file
140
+ content = File.read(view_file)
141
+ layout_match = content.match(/layout\s+['"]([^'"]+)['"]/)
142
+ layout_name = layout_match ? layout_match[1] : 'application'
143
+
144
+ # Try to find layout file
145
+ extensions = %w[erb haml slim]
146
+ extensions.each do |ext|
147
+ layout_path = "app/views/layouts/#{layout_name}.html.#{ext}"
148
+ if File.exist?(layout_path)
149
+ return layout_path
150
+ elsif defined?(Rails) && Rails.root
151
+ rails_path = Rails.root.join(layout_path)
152
+ return rails_path.to_s if File.exist?(rails_path)
153
+ end
154
+ end
155
+
156
+ # Default to application layout
157
+ extensions.each do |ext|
158
+ layout_path = "app/views/layouts/application.html.#{ext}"
159
+ if File.exist?(layout_path)
160
+ return layout_path
161
+ elsif defined?(Rails) && Rails.root
162
+ rails_path = Rails.root.join(layout_path)
163
+ return rails_path.to_s if File.exist?(rails_path)
164
+ end
165
+ end
166
+
167
+ nil
168
+ end
169
+
170
+ # Recursively find all partials rendered in a file
171
+ # Handles nested partials, collections, and all Rails render patterns
172
+ def find_all_partials_recursive(file)
173
+ # Normalize file path to consistent format
174
+ file_path = normalize_path(file)
175
+ return unless file_path && File.exist?(file_path)
176
+ return if @visited_files.include?(file_path)
177
+
178
+ @visited_files.add(file_path)
179
+ content = File.read(file_path)
180
+
181
+ # Find all partials rendered in this file
182
+ # Use the PartialDetection module method (handles all Rails patterns)
183
+ partials = find_partials_in_view_file(file_path)
184
+
185
+ # Also check for Rails shorthand: render @model (renders _model.html.erb)
186
+ content.scan(/render\s+@(\w+)/) do |match|
187
+ model_name = match[0]
188
+ partial_name = model_name.underscore
189
+ partials << partial_name unless partials.include?(partial_name)
190
+ end
191
+
192
+ partials.each do |partial_name|
193
+ partial_file = find_partial_file(partial_name)
194
+ next unless partial_file
195
+
196
+ # Normalize partial file path to consistent format
197
+ full_partial_path = normalize_path(partial_file)
198
+ next unless full_partial_path && File.exist?(full_partial_path)
199
+ next if @all_files.include?(full_partial_path)
200
+
201
+ @all_files << full_partial_path
202
+
203
+ # Recursively find partials within this partial (handles nested partials)
204
+ find_all_partials_recursive(full_partial_path)
205
+ end
206
+ end
207
+
208
+ # Find the actual file path for a partial name
209
+ def find_partial_file(partial_name)
210
+ extensions = %w[erb haml slim]
211
+
212
+ # Handle namespaced partials (e.g., 'layouts/_navbar', 'layouts/navbar')
213
+ if partial_name.include?('/')
214
+ parts = partial_name.split('/')
215
+ dir = parts[0..-2].join('/')
216
+ name = parts.last
217
+ # Remove leading underscore from name if present
218
+ name = name.start_with?('_') ? name[1..-1] : name
219
+ partial_path = "app/views/#{dir}/_#{name}"
220
+
221
+ extensions.each do |ext|
222
+ full_path = "#{partial_path}.html.#{ext}"
223
+ # Try with Rails.root first (most reliable)
224
+ if defined?(Rails) && Rails.root
225
+ rails_path = Rails.root.join(full_path)
226
+ return rails_path.to_s if File.exist?(rails_path)
227
+ end
228
+ # Fallback to relative path
229
+ if File.exist?(full_path)
230
+ return full_path
231
+ end
232
+ end
233
+ else
234
+ # Non-namespaced partial - remove leading underscore if present
235
+ clean_name = partial_name.start_with?('_') ? partial_name[1..-1] : partial_name
236
+
237
+ # Try view directory first (same directory as view file)
238
+ # Handle both absolute and relative paths
239
+ view_dir_path = File.dirname(@view_file)
240
+ if view_dir_path.include?('app/views/')
241
+ # Extract directory relative to app/views
242
+ view_dir = view_dir_path.sub(/^.*\/app\/views\//, '')
243
+ else
244
+ view_dir = view_dir_path.sub('app/views/', '')
245
+ end
246
+ partial_path = "app/views/#{view_dir}/_#{clean_name}"
247
+
248
+ extensions.each do |ext|
249
+ full_path = "#{partial_path}.html.#{ext}"
250
+ # Try with Rails.root if file doesn't exist (for absolute paths)
251
+ if File.exist?(full_path)
252
+ return full_path
253
+ elsif defined?(Rails) && Rails.root
254
+ rails_path = Rails.root.join(full_path)
255
+ return rails_path.to_s if File.exist?(rails_path)
256
+ end
257
+ end
258
+
259
+ # Try standard directories first (most common locations)
260
+ standard_dirs = ['layouts', 'shared', 'application']
261
+ standard_dirs.each do |dir|
262
+ partial_path = "app/views/#{dir}/_#{clean_name}"
263
+ extensions.each do |ext|
264
+ full_path = "#{partial_path}.html.#{ext}"
265
+ # Try with Rails.root first
266
+ if defined?(Rails) && Rails.root
267
+ rails_path = Rails.root.join(full_path)
268
+ return rails_path.to_s if File.exist?(rails_path)
269
+ end
270
+ # Fallback to relative path
271
+ if File.exist?(full_path)
272
+ return full_path
273
+ end
274
+ end
275
+ end
276
+
277
+ # Exhaustive search: traverse ALL folders in app/views recursively
278
+ # This ensures we find partials in any subdirectory (collections, items, profiles, loan_requests, etc.)
279
+ # Only do this if partial wasn't found in standard locations (performance optimization)
280
+ if defined?(Rails) && Rails.root
281
+ views_dir = Rails.root.join('app', 'views')
282
+ if File.exist?(views_dir)
283
+ # Search for partial in all subdirectories (use first match found)
284
+ extensions.each do |ext|
285
+ pattern = views_dir.join('**', "_#{clean_name}.html.#{ext}")
286
+ found_path = Dir.glob(pattern).first
287
+ return found_path if found_path && File.exist?(found_path)
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ nil
294
+ end
295
+
296
+ # Find line number for an element in the original ERB file
297
+ def find_line_number(content, element)
298
+ # Simple approach: find the line containing the tag
299
+ tag_name = element.name
300
+ text = element.text.strip[0..50] # First 50 chars for matching
301
+
302
+ lines = content.split("\n")
303
+ lines.each_with_index do |line, index|
304
+ if line.include?("<#{tag_name}") && (text.empty? || line.include?(text[0..20]))
305
+ return index + 1
306
+ end
307
+ end
308
+
309
+ 1 # Default to line 1 if not found
310
+ end
311
+ end
312
+ end
313
+
@@ -55,6 +55,8 @@ require_relative 'rails_accessibility_testing/line_number_finder'
55
55
  require_relative 'rails_accessibility_testing/violation_converter'
56
56
  require_relative 'rails_accessibility_testing/static_file_scanner'
57
57
  require_relative 'rails_accessibility_testing/static_scanning'
58
+ require_relative 'rails_accessibility_testing/view_composition_builder'
59
+ require_relative 'rails_accessibility_testing/composed_page_scanner'
58
60
  # Only load RSpec-specific components when RSpec is available
59
61
  if defined?(RSpec)
60
62
  require_relative 'rails_accessibility_testing/shared_examples'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_accessibility_testing
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.8
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Regan Maharjan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-01 00:00:00.000000000 Z
11
+ date: 2025-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: axe-core-capybara
@@ -143,6 +143,7 @@ files:
143
143
  - lib/rails_accessibility_testing/checks/skip_links_check.rb
144
144
  - lib/rails_accessibility_testing/checks/table_structure_check.rb
145
145
  - lib/rails_accessibility_testing/cli/command.rb
146
+ - lib/rails_accessibility_testing/composed_page_scanner.rb
146
147
  - lib/rails_accessibility_testing/config/yaml_loader.rb
147
148
  - lib/rails_accessibility_testing/configuration.rb
148
149
  - lib/rails_accessibility_testing/engine/rule_engine.rb
@@ -161,6 +162,7 @@ files:
161
162
  - lib/rails_accessibility_testing/static_page_adapter.rb
162
163
  - lib/rails_accessibility_testing/static_scanning.rb
163
164
  - lib/rails_accessibility_testing/version.rb
165
+ - lib/rails_accessibility_testing/view_composition_builder.rb
164
166
  - lib/rails_accessibility_testing/violation_converter.rb
165
167
  - lib/tasks/accessibility.rake
166
168
  homepage: https://rayraycodes.github.io/rails-accessibility-testing/