rails_accessibility_testing 1.5.8 → 1.5.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: deb1a679b569b2ea1335bd383dd3c3c35a604291cd504acf83b02d6bc8093a33
4
- data.tar.gz: beeb61ac0b6d7ba710485c03f340427943b5580e77b04c5d77b4b2ea2b395e16
3
+ metadata.gz: 9f8824e08bc8db71c7a6598998b44c6b39826f367398c8c98a1ef85366a044e2
4
+ data.tar.gz: bd9337c4b6b89deae25b1adfc9dbec92645c934703e930d5f5048c71a59117b5
5
5
  SHA512:
6
- metadata.gz: e5712af98873a226fe1d6b834e4c57983897787859adcd4613c24dafd4c2cd60ed9629b6b4865d595dadcfd2452e60766d4ac703a41ffe5ca98a5510801cc9c2
7
- data.tar.gz: b3d7cad5a19ddd2b502e4e5a44721e8ecfb787c901ffe65d5904cd98daea202d5d4cd04c8da454f28158d98e2d5450166a6cee953681e762f9aad636c4c2f863
6
+ metadata.gz: cd9fdfced6342b861ae5d1db4ee852b4c566c9572d62dac463fdc0d7566d37ef1201201c36c3af4d45393d24af7f0a504f76f7eaf25322e7025a577eeb3d94dc
7
+ data.tar.gz: a2672850fd85a29fb3a304243f1367215c282a34e63df58f5878d274dd31176465afaad23734699124bd6649032b68ff578f0d6a1ae4b6ec853d2f4ffd7ef23c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.10] - 2024-12-01
9
+
10
+ ### Changed
11
+ - **Configuration flag renamed**: The `enabled` flag has been renamed to `accessibility_enabled` for better clarity. The old `enabled` key is still supported for backward compatibility.
12
+
13
+ ### Added
14
+ - **Composed Page Scanning**: The static scanner now analyzes complete page compositions (layout + view + partials) for page-level accessibility checks, eliminating false positives for heading hierarchy, ARIA landmarks, duplicate IDs, and heading issues.
15
+ - **View Composition Builder**: New comprehensive system that traces the complete page structure by finding all partials recursively across all directories in `app/views`.
16
+ - **Exhaustive Partial Detection**: Enhanced partial detection that finds partials in any subdirectory, not just specific folders. Works for deeply nested namespaces (e.g., `collections/collection_questions`).
17
+ - **ERB Content Detection**: Improved handling of ERB expressions (`<%= ... %>`) to prevent false positives for empty headings and missing accessible names on buttons.
18
+
19
+ ### Changed
20
+ - **Heading Hierarchy Checks**: Now performed on the complete composed page instead of individual files, preventing false positives when H1 is in layout or partials.
21
+ - **ARIA Landmarks Checks**: Now checks for `<main>` landmark across the entire composed page.
22
+ - **Duplicate ID Checks**: Now checks for duplicate IDs across the complete page composition.
23
+ - **Empty Heading Checks**: Now checks for empty headings across the complete composed page.
24
+ - **Partial Search**: Enhanced to traverse ALL folders in `app/views` recursively using `Dir.glob`, making it a general solution that works for any folder structure.
25
+
26
+ ### Fixed
27
+ - Fixed false positive for "Page missing MAIN landmark" when `<main>` is in the layout file.
28
+ - Fixed false positive for "Page has h2 but no h1 heading" when H1 is in a partial rendered via `render @model`.
29
+ - Fixed false positive for "Heading hierarchy skipped (h0 to h2)" when first heading is h2.
30
+ - Fixed false positive for "Duplicate ID 'ERB_CONTENT'" by filtering out ERB placeholder strings.
31
+ - Fixed partial detection for Rails shorthand patterns (`render @model`, `render collection: @models`).
32
+ - Fixed namespaced partial path resolution (e.g., `layouts/_advance_search`).
33
+ - Fixed path normalization to handle both relative and absolute paths consistently.
34
+
35
+ ### Performance
36
+ - Optimized exhaustive directory search to return first match found instead of checking all matches.
37
+
38
+ ## [Unreleased]
39
+
40
+ ### Added
41
+ - **Composed Page Scanning**: The static scanner now analyzes complete page compositions (layout + view + partials) for page-level accessibility checks, eliminating false positives for heading hierarchy, ARIA landmarks, duplicate IDs, and heading issues.
42
+ - **View Composition Builder**: New comprehensive system that traces the complete page structure by finding all partials recursively across all directories in `app/views`.
43
+ - **Exhaustive Partial Detection**: Enhanced partial detection that finds partials in any subdirectory, not just specific folders. Works for deeply nested namespaces (e.g., `collections/collection_questions`).
44
+ - **ERB Content Detection**: Improved handling of ERB expressions (`<%= ... %>`) to prevent false positives for empty headings and missing accessible names on buttons.
45
+
46
+ ### Changed
47
+ - **Heading Hierarchy Checks**: Now performed on the complete composed page instead of individual files, preventing false positives when H1 is in layout or partials.
48
+ - **ARIA Landmarks Checks**: Now checks for `<main>` landmark across the entire composed page.
49
+ - **Duplicate ID Checks**: Now checks for duplicate IDs across the complete page composition.
50
+ - **Empty Heading Checks**: Now checks for empty headings across the complete composed page.
51
+ - **Partial Search**: Enhanced to traverse ALL folders in `app/views` recursively using `Dir.glob`, making it a general solution that works for any folder structure.
52
+
53
+ ### Fixed
54
+ - Fixed false positive for "Page missing MAIN landmark" when `<main>` is in the layout file.
55
+ - Fixed false positive for "Page has h2 but no h1 heading" when H1 is in a partial rendered via `render @model`.
56
+ - Fixed false positive for "Heading hierarchy skipped (h0 to h2)" when first heading is h2.
57
+ - Fixed false positive for "Duplicate ID 'ERB_CONTENT'" by filtering out ERB placeholder strings.
58
+ - Fixed partial detection for Rails shorthand patterns (`render @model`, `render collection: @models`).
59
+ - Fixed namespaced partial path resolution (e.g., `layouts/_advance_search`).
60
+ - Fixed path normalization to handle both relative and absolute paths consistently.
61
+
62
+ ### Performance
63
+ - Optimized exhaustive directory search to return first match found instead of checking all matches.
64
+
65
+ ## [1.5.9] - 2024-12-01
66
+
67
+ ### Added
68
+ - **Force option for manual checks**: Added `force: true` parameter to `check_comprehensive_accessibility` and `check_basic_accessibility` to bypass `enabled: false` setting
69
+ - **Manual check flexibility**: You can now force manual checks to run even when accessibility checks are globally disabled
70
+
71
+ ### Improved
72
+ - **Manual check documentation**: Clarified that manual checks can be run separately in RSpec specs regardless of configuration
73
+
8
74
  ## [1.5.8] - 2024-12-01
9
75
 
10
76
  ### Fixed
data/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  **The RSpec + RuboCop of accessibility for Rails. Catch WCAG violations before they reach production.**
9
9
 
10
- **Current Version:** 1.5.5
10
+ **Current Version:** 1.5.9
11
11
 
12
12
  📖 **[📚 Full Documentation](https://rayraycodes.github.io/rails-accessibility-testing/)** | [💻 GitHub](https://github.com/rayraycodes/rails-accessibility-testing) | [💎 RubyGems](https://rubygems.org/gems/rails_accessibility_testing)
13
13
 
@@ -51,6 +51,13 @@ Rails Accessibility Testing fills a critical gap in the Rails testing ecosystem.
51
51
  - **Accurate error counting**: Properly tracks and displays error/warning counts
52
52
  - **Persistent output**: Errors stay visible in terminal (no clearing)
53
53
 
54
+ #### 🔍 Composed Page Scanning (NEW in 1.5.9+)
55
+ - **Complete page analysis**: Analyzes full page composition (layout + view + partials) for page-level checks
56
+ - **Eliminates false positives**: No more false positives when H1 is in layout or partials
57
+ - **Exhaustive partial finding**: Traverses ALL folders recursively to find all partials
58
+ - **Works for any structure**: General solution that works for any Rails folder structure (collections, items, profiles, loan_requests, etc.)
59
+ - **Page-level checks**: Heading hierarchy, ARIA landmarks, duplicate IDs, and empty headings checked across complete page
60
+
54
61
  #### 🔍 Smart View File Detection
55
62
  - **Intelligent matching**: Automatically finds view files even when action names don't match
56
63
  - **Controller directory scanning**: Searches all view files to find the correct template
@@ -61,6 +68,7 @@ Rails Accessibility Testing fills a critical gap in the Rails testing ecosystem.
61
68
  - **Optimized DOM queries**: Faster image alt checks without JavaScript evaluation
62
69
  - **Removed delays**: Eliminated unnecessary sleep calls in live scanner
63
70
  - **Efficient scanning**: ~25-30% faster page scans
71
+ - **Optimized directory search**: Returns first match found instead of checking all matches
64
72
 
65
73
  #### 🎨 Enhanced Developer Experience
66
74
  - **Real-time progress**: Step-by-step feedback during accessibility checks
@@ -3,7 +3,45 @@ layout: default
3
3
  title: Architecture
4
4
  ---
5
5
 
6
- # Architecture Overview
6
+ # Architecture
7
+
8
+ ## Composed Page Scanning
9
+
10
+ The gem now uses **composed page scanning** for page-level accessibility checks. This ensures that checks like heading hierarchy, ARIA landmarks, and duplicate IDs are evaluated against the complete rendered page (layout + view + partials), not individual files.
11
+
12
+ ### View Composition Builder
13
+
14
+ The `ViewCompositionBuilder` class traces the complete page structure:
15
+
16
+ 1. **Finds Layout File**: Identifies the layout file (defaults to `application.html.erb`)
17
+ 2. **Finds View File**: The main view file being rendered
18
+ 3. **Recursively Finds Partials**: Discovers all partials rendered in the view, including:
19
+ - Partials in the same directory
20
+ - Partials in `layouts/`, `shared/`, `application/`
21
+ - Partials in any subdirectory (exhaustive search)
22
+ - Nested partials (partials within partials)
23
+
24
+ ### Partial Detection
25
+
26
+ The gem detects all Rails render patterns:
27
+ - `render 'partial'`
28
+ - `render partial: 'partial'`
29
+ - `render @model` (Rails shorthand)
30
+ - `render collection: @models`
31
+ - `render partial: 'item', collection: @items`
32
+ - `render partial: 'form', locals: {...}`
33
+
34
+ ### Exhaustive Folder Traversal
35
+
36
+ The partial search traverses ALL folders in `app/views` recursively using `Dir.glob`, ensuring partials are found regardless of their location:
37
+ - `app/views/collections/`
38
+ - `app/views/collections/collection_questions/`
39
+ - `app/views/items/`
40
+ - `app/views/profiles/`
41
+ - `app/views/loan_requests/`
42
+ - Any other nested structure
43
+
44
+ This makes it a general solution that works for any Rails application structure. Overview
7
45
 
8
46
  This guide explains how Rails Accessibility Testing works under the hood in simple terms.
9
47
 
@@ -144,12 +144,12 @@ Rails Accessibility Testing runs **11 comprehensive checks** automatically. Thes
144
144
  1. **Form Labels** - All form inputs have associated labels
145
145
  2. **Image Alt Text** - All images have descriptive alt attributes
146
146
  3. **Interactive Elements** - Buttons, links have accessible names
147
- 4. **Heading Hierarchy** - Proper h1-h6 structure without skipping levels
147
+ 4. **Heading Hierarchy** - Proper h1-h6 structure without skipping levels (checked across complete page: layout + view + partials)
148
148
  5. **Keyboard Accessibility** - All interactive elements are keyboard accessible
149
- 6. **ARIA Landmarks** - Proper use of ARIA landmark roles
149
+ 6. **ARIA Landmarks** - Proper use of ARIA landmark roles (checked across complete page: layout + view + partials)
150
150
  7. **Form Error Associations** - Form errors are properly linked to form fields
151
151
  8. **Table Structure** - Tables have proper headers
152
- 9. **Duplicate IDs** - No duplicate ID attributes
152
+ 9. **Duplicate IDs** - No duplicate ID attributes (checked across complete page: layout + view + partials)
153
153
  10. **Skip Links** - Skip navigation links present
154
154
  11. **Color Contrast** - Text meets WCAG contrast requirements (optional, disabled by default)
155
155
 
data/docs_site/index.md CHANGED
@@ -7,7 +7,7 @@ title: Home
7
7
 
8
8
  **The RSpec + RuboCop of accessibility for Rails. Catch WCAG violations before they reach production.**
9
9
 
10
- **Version:** 1.5.5
10
+ **Version:** 1.5.9
11
11
 
12
12
  Rails Accessibility Testing is a comprehensive, opinionated but configurable gem that makes accessibility testing as natural as unit testing. It integrates seamlessly into your Rails workflow, catching accessibility issues as you code—not after deployment.
13
13
 
@@ -35,10 +35,11 @@ begin
35
35
  require 'rails_accessibility_testing/config/yaml_loader'
36
36
  profile = defined?(Rails) && Rails.env.test? ? :test : :development
37
37
  config = RailsAccessibilityTesting::Config::YamlLoader.load(profile: profile)
38
- enabled = config.fetch('enabled', true)
38
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
39
+ enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true))
39
40
  unless enabled
40
- puts "⏸️ Accessibility checks are disabled (enabled: false in config/accessibility.yml)"
41
- puts " Set enabled: true to enable accessibility scanning"
41
+ puts "⏸️ Accessibility checks are disabled (accessibility_enabled: false in config/accessibility.yml)"
42
+ puts " Set accessibility_enabled: true to enable accessibility scanning"
42
43
  puts " Scanner process will remain running but will not scan files"
43
44
  puts ""
44
45
  # Keep process alive so Foreman doesn't kill other processes
@@ -29,10 +29,11 @@ begin
29
29
  require 'rails_accessibility_testing/config/yaml_loader'
30
30
  profile = defined?(Rails) && Rails.env.test? ? :test : :development
31
31
  config = RailsAccessibilityTesting::Config::YamlLoader.load(profile: profile)
32
- enabled = config.fetch('enabled', true)
32
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
33
+ enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true))
33
34
  unless enabled
34
- puts "⏸️ Accessibility checks are disabled (enabled: false in config/accessibility.yml)"
35
- puts " Set enabled: true to enable accessibility scanning"
35
+ puts "⏸️ Accessibility checks are disabled (accessibility_enabled: false in config/accessibility.yml)"
36
+ puts " Set accessibility_enabled: true to enable accessibility scanning"
36
37
  puts " Scanner process will remain running but will not scan files"
37
38
  puts ""
38
39
  # Keep process alive so Foreman doesn't kill other processes
@@ -3,11 +3,11 @@
3
3
  # This file configures accessibility checks for your Rails application.
4
4
  # See https://github.com/your-org/rails-a11y for full documentation.
5
5
 
6
- # Global enable/disable flag
6
+ # Global enable/disable flag for all accessibility checks
7
7
  # Set to false to completely disable all accessibility checks (manual and automatic)
8
8
  # When false, check_comprehensive_accessibility and automatic checks will be skipped
9
9
  # Default: true
10
- enabled: true
10
+ accessibility_enabled: true
11
11
 
12
12
  # WCAG compliance level (A, AA, AAA)
13
13
  wcag_level: AA
@@ -46,14 +46,34 @@ module RailsAccessibilityTesting
46
46
  # render partial: "partial_name"
47
47
  # render 'path/to/partial'
48
48
  # render partial: 'path/to/partial'
49
+ # render @model (Rails shorthand - renders _model.html.erb)
50
+ # render collection: @models (renders _model.html.erb for each)
51
+ # render partial: 'partial', locals: {...}
52
+ # render partial: 'partial', collection: @items
49
53
  # <%= render 'partial_name' %>
50
54
  # <%= render partial: 'partial_name' %>
55
+ # <%= render @model %>
56
+ # <%= render collection: @models %>
51
57
 
52
58
  patterns = [
59
+ # Standard partial renders (with or without partial: keyword)
53
60
  /render\s+(?:partial:\s*)?['"]([^'"]+)['"]/,
54
61
  /render\s+(?:partial:\s*)?:(\w+)/,
55
62
  /<%=?\s*render\s+(?:partial:\s*)?['"]([^'"]+)['"]/,
56
- /<%=?\s*render\s+(?:partial:\s*)?:(\w+)/
63
+ /<%=?\s*render\s+(?:partial:\s*)?:(\w+)/,
64
+ # Rails shorthand: render @model (renders _model.html.erb)
65
+ /render\s+@(\w+)/,
66
+ /<%=?\s*render\s+@(\w+)/,
67
+ # Collection renders: render collection: @models (renders _model.html.erb)
68
+ /render\s+collection:\s*@(\w+)/,
69
+ /<%=?\s*render\s+collection:\s*@(\w+)/,
70
+ # Partial with collection: render partial: 'item', collection: @items
71
+ /render\s+partial:\s*['"]([^'"]+)['"]\s*,\s*collection:/,
72
+ /<%=?\s*render\s+partial:\s*['"]([^'"]+)['"]\s*,\s*collection:/,
73
+ # Partial with locals: render partial: 'form', locals: {...}
74
+ # (extract just the partial name, ignore locals)
75
+ /render\s+partial:\s*['"]([^'"]+)['"]\s*,\s*locals:/,
76
+ /<%=?\s*render\s+partial:\s*['"]([^'"]+)['"]\s*,\s*locals:/
57
77
  ]
58
78
 
59
79
  patterns.each do |pattern|
@@ -77,6 +97,23 @@ module RailsAccessibilityTesting
77
97
  end
78
98
  end
79
99
 
100
+ # Also check for Rails shorthand: render @model (renders _model.html.erb)
101
+ # This is separate because it needs special handling
102
+ content.scan(/render\s+@(\w+)/) do |match|
103
+ model_name = match[0]
104
+ partial_name = model_name.underscore
105
+ partial_name = "_#{partial_name}" unless partial_name.start_with?('_')
106
+ partials << partial_name unless partials.include?(partial_name)
107
+ end
108
+
109
+ # Check for collection renders: render collection: @models
110
+ content.scan(/render\s+collection:\s*@(\w+)/) do |match|
111
+ model_name = match[0]
112
+ partial_name = model_name.underscore.singularize # collection uses singular
113
+ partial_name = "_#{partial_name}" unless partial_name.start_with?('_')
114
+ partials << partial_name unless partials.include?(partial_name)
115
+ end
116
+
80
117
  partials
81
118
  end
82
119
 
@@ -210,9 +247,11 @@ module RailsAccessibilityTesting
210
247
  end
211
248
 
212
249
  # Basic accessibility check - runs 5 basic checks
213
- def check_basic_accessibility
250
+ # @param force [Boolean] If true, bypasses the accessibility_enabled: false setting (default: false)
251
+ def check_basic_accessibility(force: false)
214
252
  # Check if accessibility checks are globally disabled
215
- return if accessibility_disabled?
253
+ # Manual calls can use force: true to bypass the accessibility_enabled: false setting
254
+ return if !force && accessibility_disabled?
216
255
 
217
256
  @accessibility_errors ||= []
218
257
  @accessibility_warnings ||= []
@@ -244,10 +283,12 @@ module RailsAccessibilityTesting
244
283
 
245
284
  # Full comprehensive check - runs all 11 checks including advanced
246
285
  # Uses the RuleEngine and checks from the checks/ folder for consistency
286
+ # @param force [Boolean] If true, bypasses the accessibility_enabled: false setting (default: false)
247
287
  # @return [Hash] Hash with :errors and :warnings counts
248
- def check_comprehensive_accessibility
288
+ def check_comprehensive_accessibility(force: false)
249
289
  # Check if accessibility checks are globally disabled - do this FIRST before any output
250
- if accessibility_disabled?
290
+ # Manual calls can use force: true to bypass the accessibility_enabled: false setting
291
+ if !force && accessibility_disabled?
251
292
  return { errors: 0, warnings: 0, page_context: {} }
252
293
  end
253
294
 
@@ -447,7 +488,8 @@ module RailsAccessibilityTesting
447
488
  require 'rails_accessibility_testing/config/yaml_loader'
448
489
  profile = defined?(Rails) && Rails.env.test? ? :test : :development
449
490
  config = RailsAccessibilityTesting::Config::YamlLoader.load(profile: profile)
450
- enabled = config.fetch('enabled', true) # Default to enabled if not specified
491
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
492
+ enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true)) # Default to enabled if not specified
451
493
  disabled = !enabled # Return true if disabled
452
494
  disabled
453
495
  rescue StandardError => e
@@ -23,7 +23,7 @@ module RailsAccessibilityTesting
23
23
  return violations # Warning only, not error
24
24
  end
25
25
 
26
- # Check 1: Missing H1
26
+ # Check 1: Missing H1 (page-level check - checks complete composed page)
27
27
  h1_count = headings.count { |h| h.tag_name == 'h1' }
28
28
  first_heading = headings.first
29
29
  first_heading_level = first_heading ? first_heading.tag_name[1].to_i : nil
@@ -32,30 +32,35 @@ module RailsAccessibilityTesting
32
32
  # If the first heading is h2 or higher, provide a more specific message
33
33
  if first_heading_level && first_heading_level >= 2
34
34
  element_ctx = element_context(first_heading)
35
+ # Mark as page-level check - H1 might be in layout or partial
36
+ element_ctx[:page_level_check] = true
37
+ element_ctx[:check_type] = 'heading_hierarchy'
35
38
  violations << violation(
36
- message: "Page has h#{first_heading_level} but no h1 heading",
39
+ message: "Page has h#{first_heading_level} but no h1 heading (checked complete page: layout + view + partials)",
37
40
  element_context: element_ctx,
38
41
  wcag_reference: "1.3.1",
39
- remediation: "Add an <h1> heading before the first h#{first_heading_level}:\n\n<h1>Main Page Title</h1>\n<h#{first_heading_level}>#{first_heading.text}</h#{first_heading_level}>"
42
+ remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>\n<h#{first_heading_level}>#{first_heading.text}</h#{first_heading_level}>"
40
43
  )
41
44
  else
42
45
  violations << violation(
43
- message: "Page missing H1 heading",
44
- element_context: { tag: 'page', text: 'Page has no H1 heading' },
46
+ message: "Page missing H1 heading (checked complete page: layout + view + partials)",
47
+ element_context: { tag: 'page', text: 'Page has no H1 heading', page_level_check: true, check_type: 'heading_hierarchy' },
45
48
  wcag_reference: "1.3.1",
46
- remediation: "Add an <h1> heading to your page:\n\n<h1>Main Page Title</h1>"
49
+ remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>"
47
50
  )
48
51
  end
49
52
  end
50
53
 
51
- # Check 2: Multiple H1s (WCAG 1.3.1)
54
+ # Check 2: Multiple H1s (WCAG 1.3.1) - page-level check
52
55
  if h1_count > 1
53
56
  # Report error for each h1 after the first one
54
57
  h1_elements = headings.select { |h| h.tag_name == 'h1' }
55
58
  h1_elements[1..-1].each do |h1|
56
59
  element_ctx = element_context(h1)
60
+ element_ctx[:page_level_check] = true
61
+ element_ctx[:check_type] = 'heading_hierarchy'
57
62
  violations << violation(
58
- message: "Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page",
63
+ message: "Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page (checked complete page: layout + view + partials)",
59
64
  element_context: element_ctx,
60
65
  wcag_reference: "1.3.1",
61
66
  remediation: "Use only one <h1> per page. Convert additional h1s to h2 or lower:\n\n<h1>Main Title</h1>\n<h2>Section Title</h2>"
@@ -63,14 +68,16 @@ module RailsAccessibilityTesting
63
68
  end
64
69
  end
65
70
 
66
- # Check 3: Heading hierarchy skipped levels (WCAG 1.3.1)
71
+ # Check 3: Heading hierarchy skipped levels (WCAG 1.3.1) - page-level check
67
72
  previous_level = 0
68
73
  headings.each do |heading|
69
74
  current_level = heading.tag_name[1].to_i
70
75
  if current_level > previous_level + 1
71
76
  element_ctx = element_context(heading)
77
+ element_ctx[:page_level_check] = true
78
+ element_ctx[:check_type] = 'heading_hierarchy'
72
79
  violations << violation(
73
- message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level})",
80
+ message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level}) - checked complete page: layout + view + partials",
74
81
  element_context: element_ctx,
75
82
  wcag_reference: "1.3.1",
76
83
  remediation: "Fix the heading hierarchy. Don't skip levels. Use h#{previous_level + 1} instead of h#{current_level}."
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require_relative 'view_composition_builder'
5
+ require_relative 'erb_extractor'
6
+
7
+ module RailsAccessibilityTesting
8
+ # Scans a complete composed page (layout + view + partials) for heading hierarchy
9
+ # This ensures heading hierarchy is checked across the entire page, not just individual files
10
+ #
11
+ # @api private
12
+ class ComposedPageScanner
13
+ def initialize(view_file)
14
+ @view_file = view_file
15
+ @builder = ViewCompositionBuilder.new(view_file)
16
+ end
17
+
18
+ # Scan the complete composed page for heading hierarchy violations
19
+ # @return [Hash] Hash with :errors and :warnings arrays
20
+ def scan_heading_hierarchy
21
+ return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
22
+
23
+ # Build composition
24
+ all_files = @builder.build
25
+ return { errors: [], warnings: [] } if all_files.empty?
26
+
27
+ # Get all headings from complete composition
28
+ headings = @builder.all_headings
29
+ return { errors: [], warnings: [] } if headings.empty?
30
+
31
+ violations = []
32
+
33
+ # Check 1: Missing H1
34
+ h1_count = headings.count { |h| h[:level] == 1 }
35
+ first_heading = headings.first
36
+
37
+ if h1_count == 0
38
+ if first_heading
39
+ violations << create_violation(
40
+ message: "Page has h#{first_heading[:level]} but no h1 heading (checked complete page: layout + view + partials)",
41
+ heading: first_heading,
42
+ wcag_reference: "1.3.1",
43
+ remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>"
44
+ )
45
+ else
46
+ violations << create_violation(
47
+ message: "Page missing H1 heading (checked complete page: layout + view + partials)",
48
+ heading: nil,
49
+ wcag_reference: "1.3.1",
50
+ remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>"
51
+ )
52
+ end
53
+ end
54
+
55
+ # Check 2: Multiple H1s
56
+ if h1_count > 1
57
+ h1_headings = headings.select { |h| h[:level] == 1 }
58
+ h1_headings[1..-1].each do |h1|
59
+ violations << create_violation(
60
+ message: "Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page (checked complete page: layout + view + partials)",
61
+ heading: h1,
62
+ wcag_reference: "1.3.1",
63
+ remediation: "Use only one <h1> per page. Convert additional h1s to h2 or lower:\n\n<h1>Main Title</h1>\n<h2>Section Title</h2>"
64
+ )
65
+ end
66
+ end
67
+
68
+ # Check 3: Heading hierarchy skipped levels
69
+ # Only flag if we're skipping from a real heading (not from h0)
70
+ # If the first heading is h2, that's fine - it just means no h1 exists (handled by Check 1)
71
+ previous_level = nil
72
+ headings.each do |heading|
73
+ current_level = heading[:level]
74
+ if previous_level && current_level > previous_level + 1
75
+ # Only flag if we're skipping from a real heading level
76
+ violations << create_violation(
77
+ message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level}) - checked complete page: layout + view + partials",
78
+ heading: heading,
79
+ wcag_reference: "1.3.1",
80
+ remediation: "Fix the heading hierarchy. Don't skip levels. Use h#{previous_level + 1} instead of h#{current_level}."
81
+ )
82
+ end
83
+ previous_level = current_level
84
+ end
85
+
86
+ # Convert to errors/warnings format (matching ViolationConverter format)
87
+ errors = violations.map do |v|
88
+ {
89
+ type: v[:message],
90
+ element: {
91
+ tag: v[:heading] ? "h#{v[:heading][:level]}" : 'page',
92
+ text: v[:heading] ? v[:heading][:text] : 'Page-level heading hierarchy check',
93
+ file: v[:file] || @view_file
94
+ },
95
+ file: v[:file] || @view_file,
96
+ line: v[:line] || 1,
97
+ wcag: v[:wcag_reference],
98
+ remediation: v[:remediation],
99
+ page_level_check: true,
100
+ check_type: 'heading_hierarchy'
101
+ }
102
+ end
103
+
104
+ { errors: errors, warnings: [] }
105
+ end
106
+
107
+ # Scan the complete composed page for all heading issues (not just hierarchy)
108
+ # This includes: empty headings, styling-only headings, etc.
109
+ # @return [Hash] Hash with :errors and :warnings arrays
110
+ def scan_all_headings
111
+ return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
112
+
113
+ # Build composition
114
+ all_files = @builder.build
115
+ return { errors: [], warnings: [] } if all_files.empty?
116
+
117
+ # Get all headings from complete composition
118
+ headings = @builder.all_headings
119
+ return { errors: [], warnings: [] } if headings.empty?
120
+
121
+ errors = []
122
+ warnings = []
123
+
124
+ # Check for empty headings (across complete page)
125
+ headings.each do |heading|
126
+ heading_text = heading[:text]
127
+
128
+ # Check if heading contains ERB placeholder
129
+ has_erb_content = heading_text.include?('ERB_CONTENT')
130
+
131
+ # Check if heading is empty or only contains whitespace
132
+ if (heading_text.empty? || heading_text.match?(/^\s*$/)) && !has_erb_content
133
+ errors << {
134
+ type: "Empty heading detected (<h#{heading[:level]}>) - headings must have accessible text (checked complete page: layout + view + partials)",
135
+ element: {
136
+ tag: "h#{heading[:level]}",
137
+ text: 'Empty heading',
138
+ file: heading[:file]
139
+ },
140
+ file: heading[:file],
141
+ line: heading[:line],
142
+ wcag: "4.1.2",
143
+ remediation: "Add descriptive text to the heading:\n\n<h#{heading[:level]}>Descriptive Heading Text</h#{heading[:level]}>",
144
+ page_level_check: true,
145
+ check_type: 'heading_empty'
146
+ }
147
+ end
148
+
149
+ # Check for styling-only headings (very short or generic text)
150
+ if heading_text.length <= 2 && heading_text.match?(/^[•→…\s\-_=]+$/)
151
+ warnings << {
152
+ type: "Heading appears to be used for styling only (text: '#{heading_text}') - headings should be descriptive (checked complete page: layout + view + partials)",
153
+ element: {
154
+ tag: "h#{heading[:level]}",
155
+ text: heading_text,
156
+ file: heading[:file]
157
+ },
158
+ file: heading[:file],
159
+ line: heading[:line],
160
+ wcag: "2.4.6",
161
+ remediation: "Use CSS for styling instead of headings. Replace with a <div> or <span> with appropriate CSS classes.",
162
+ page_level_check: true,
163
+ check_type: 'heading_styling'
164
+ }
165
+ end
166
+ end
167
+
168
+ { errors: errors, warnings: warnings }
169
+ end
170
+
171
+ # Scan the complete composed page for duplicate IDs
172
+ # IDs must be unique across the entire page (layout + view + partials)
173
+ # @return [Hash] Hash with :errors and :warnings arrays
174
+ def scan_duplicate_ids
175
+ return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
176
+
177
+ # Build composition
178
+ all_files = @builder.build
179
+ return { errors: [], warnings: [] } if all_files.empty?
180
+
181
+ # Collect all IDs from complete composition
182
+ id_map = {} # id => [{ file: String, line: Integer, element: String }]
183
+
184
+ all_files.each do |file|
185
+ # Handle both relative and absolute paths
186
+ file_path = if File.exist?(file)
187
+ file
188
+ elsif defined?(Rails) && Rails.root
189
+ rails_path = Rails.root.join(file)
190
+ rails_path.exist? ? rails_path.to_s : nil
191
+ else
192
+ nil
193
+ end
194
+ next unless file_path && File.exist?(file_path)
195
+
196
+ content = File.read(file_path)
197
+ html_content = ErbExtractor.extract_html(content)
198
+ doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
199
+
200
+ # Find all elements with IDs
201
+ doc.css('[id]').each do |element|
202
+ id = element[:id]
203
+ next if id.nil? || id.to_s.strip.empty?
204
+ # Skip ERB_CONTENT placeholder - it's not a real ID
205
+ next if id == 'ERB_CONTENT'
206
+
207
+ id_map[id] ||= []
208
+ line = find_line_number_for_element(content, element)
209
+ id_map[id] << {
210
+ file: file_path, # Use resolved path
211
+ line: line,
212
+ element: element.name
213
+ }
214
+ end
215
+ end
216
+
217
+ errors = []
218
+
219
+ # Find duplicate IDs
220
+ id_map.each do |id, occurrences|
221
+ if occurrences.length > 1
222
+ # Report all occurrences after the first one
223
+ occurrences[1..-1].each do |occurrence|
224
+ errors << {
225
+ type: "Duplicate ID '#{id}' found (checked complete page: layout + view + partials) - IDs must be unique across the entire page",
226
+ element: {
227
+ tag: occurrence[:element],
228
+ id: id,
229
+ file: occurrence[:file]
230
+ },
231
+ file: occurrence[:file],
232
+ line: occurrence[:line],
233
+ wcag: "4.1.1",
234
+ remediation: "Remove or rename the duplicate ID. Each ID must be unique on the page:\n\n<!-- Change one of these -->\n<div id=\"#{id}\">...</div>\n<div id=\"#{id}\">...</div>\n\n<!-- To -->\n<div id=\"#{id}\">...</div>\n<div id=\"#{id}-2\">...</div>",
235
+ page_level_check: true,
236
+ check_type: 'duplicate_ids'
237
+ }
238
+ end
239
+ end
240
+ end
241
+
242
+ { errors: errors, warnings: [] }
243
+ end
244
+
245
+ # Helper to find line number for an element
246
+ def find_line_number_for_element(content, element)
247
+ tag_name = element.name
248
+ id = element[:id]
249
+
250
+ lines = content.split("\n")
251
+ lines.each_with_index do |line, index|
252
+ if line.include?("<#{tag_name}") && (id.nil? || line.include?("id=\"#{id}\"") || line.include?("id='#{id}'"))
253
+ return index + 1
254
+ end
255
+ end
256
+
257
+ 1
258
+ end
259
+
260
+ # Scan the complete composed page for ARIA landmarks
261
+ # This ensures landmarks in layout are detected when scanning view files
262
+ # @return [Hash] Hash with :errors and :warnings arrays
263
+ def scan_aria_landmarks
264
+ return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
265
+
266
+ # Build composition
267
+ all_files = @builder.build
268
+ return { errors: [], warnings: [] } if all_files.empty?
269
+
270
+ # Check for main landmark across all files
271
+ has_main = false
272
+ main_location = nil
273
+
274
+ all_files.each do |file|
275
+ next unless File.exist?(file)
276
+
277
+ content = File.read(file)
278
+ html_content = ErbExtractor.extract_html(content)
279
+ doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
280
+
281
+ # Check for <main> tag or [role="main"]
282
+ main_elements = doc.css('main, [role="main"]')
283
+ if main_elements.any?
284
+ has_main = true
285
+ main_location = file
286
+ break
287
+ end
288
+ end
289
+
290
+ warnings = []
291
+
292
+ # Only report missing main if it's truly missing from the complete page
293
+ unless has_main
294
+ warnings << {
295
+ type: "Page missing MAIN landmark (checked complete page: layout + view + partials)",
296
+ element: {
297
+ tag: 'page',
298
+ text: 'Page-level ARIA landmark check'
299
+ },
300
+ file: @view_file,
301
+ line: 1,
302
+ wcag: "1.3.1",
303
+ remediation: "Add a <main> landmark to your page. It can be in the layout, view, or a partial:\n\n<main id=\"maincontent\">\n <!-- Page content -->\n</main>",
304
+ page_level_check: true,
305
+ check_type: 'aria_landmarks'
306
+ }
307
+ end
308
+
309
+ { errors: [], warnings: warnings }
310
+ end
311
+
312
+ private
313
+
314
+ def create_violation(message:, heading:, wcag_reference:, remediation:)
315
+ {
316
+ message: message,
317
+ file: heading ? heading[:file] : @view_file,
318
+ line: heading ? heading[:line] : 1,
319
+ wcag_reference: wcag_reference,
320
+ remediation: remediation,
321
+ heading: heading # Keep heading info for element context
322
+ }
323
+ end
324
+ end
325
+ end
326
+
@@ -84,7 +84,8 @@ module RailsAccessibilityTesting
84
84
  merged_system_specs = base_system_specs.merge(profile_system_specs)
85
85
 
86
86
  base_config.merge(
87
- 'enabled' => profile_config.fetch('enabled', base_config.fetch('enabled', true)), # Profile can override enabled, default to true
87
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
88
+ 'accessibility_enabled' => profile_config.fetch('accessibility_enabled', profile_config.fetch('enabled', base_config.fetch('accessibility_enabled', base_config.fetch('enabled', true)))), # Profile can override, supports legacy 'enabled' key
88
89
  'checks' => merged_checks,
89
90
  'summary' => merged_summary,
90
91
  'scan_strategy' => profile_config['scan_strategy'] || base_config['scan_strategy'] || 'paths',
@@ -123,7 +124,7 @@ module RailsAccessibilityTesting
123
124
  # Default configuration when no file exists
124
125
  def default_config
125
126
  {
126
- 'enabled' => true, # Global enable/disable flag for all accessibility checks
127
+ 'accessibility_enabled' => true, # Global enable/disable flag for all accessibility checks
127
128
  'wcag_level' => 'AA',
128
129
  'checks' => default_checks,
129
130
  'summary' => {
@@ -52,7 +52,8 @@ module RailsAccessibilityTesting
52
52
  require 'rails_accessibility_testing/config/yaml_loader'
53
53
  profile = defined?(Rails) && Rails.env.test? ? :test : :development
54
54
  config = Config::YamlLoader.load(profile: profile)
55
- enabled = config.fetch('enabled', true) # Default to enabled if not specified
55
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
56
+ enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true)) # Default to enabled if not specified
56
57
  !enabled # Return true if disabled
57
58
  rescue StandardError
58
59
  false # If config can't be loaded, assume enabled
@@ -36,7 +36,8 @@ module RailsAccessibilityTesting
36
36
  # Check if accessibility checks are globally disabled
37
37
  begin
38
38
  config = Config::YamlLoader.load(profile: :test)
39
- enabled = config.fetch('enabled', true)
39
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
40
+ enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true))
40
41
  return { errors: [], warnings: [] } unless enabled
41
42
  rescue StandardError
42
43
  # If config can't be loaded, continue (assume enabled)
@@ -70,14 +71,56 @@ module RailsAccessibilityTesting
70
71
  # Run all enabled checks using existing RuleEngine
71
72
  violations = engine.check(static_page, context: context)
72
73
 
73
- # Convert violations to errors/warnings format with line numbers
74
+ # For page-level checks, use composed page scanner
75
+ # This checks the complete page (layout + view + partials) for:
76
+ # - All heading checks (hierarchy, empty, styling-only)
77
+ # - ARIA landmarks (main, etc.)
78
+ # - Duplicate IDs (must be unique across entire page)
79
+ require_relative 'composed_page_scanner'
80
+ composed_scanner = ComposedPageScanner.new(@view_file)
81
+ hierarchy_result = composed_scanner.scan_heading_hierarchy
82
+ all_headings_result = composed_scanner.scan_all_headings
83
+ landmarks_result = composed_scanner.scan_aria_landmarks
84
+ duplicate_ids_result = composed_scanner.scan_duplicate_ids
85
+
86
+ # Filter out page-level violations from regular violations
87
+ # (we'll use the composed page scanner results instead)
88
+ heading_violations = violations.select { |v|
89
+ v.rule_name.to_s == 'heading'
90
+ }
91
+ landmarks_violations = violations.select { |v|
92
+ v.rule_name.to_s == 'aria_landmarks' &&
93
+ (v.message.include?('missing MAIN') || v.message.include?('missing main') ||
94
+ v.message.include?('MAIN landmark'))
95
+ }
96
+ duplicate_ids_violations = violations.select { |v|
97
+ v.rule_name.to_s == 'duplicate_ids'
98
+ }
99
+ other_violations = violations.reject { |v|
100
+ heading_violations.include?(v) || landmarks_violations.include?(v) || duplicate_ids_violations.include?(v)
101
+ }
102
+
103
+ # Convert non-page-level violations to errors/warnings format
74
104
  line_number_finder = LineNumberFinder.new(@file_content)
75
- ViolationConverter.convert(
76
- violations,
105
+ result = ViolationConverter.convert(
106
+ other_violations,
77
107
  view_file: @view_file,
78
108
  line_number_finder: line_number_finder,
79
109
  config: config
80
110
  )
111
+
112
+ # Add composed page results (all heading checks + landmarks + duplicate IDs)
113
+ result[:errors] = (result[:errors] || []) +
114
+ hierarchy_result[:errors] +
115
+ all_headings_result[:errors] +
116
+ duplicate_ids_result[:errors]
117
+ result[:warnings] = (result[:warnings] || []) +
118
+ hierarchy_result[:warnings] +
119
+ all_headings_result[:warnings] +
120
+ landmarks_result[:warnings] +
121
+ duplicate_ids_result[:warnings]
122
+
123
+ result
81
124
  rescue StandardError => e
82
125
  # If engine fails, log error and return empty results
83
126
  if defined?(Rails) && Rails.env.development?
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAccessibilityTesting
4
- VERSION = "1.5.8"
4
+ VERSION = "1.5.10"
5
5
  end
6
6
 
@@ -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,7 +1,7 @@
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.5.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Regan Maharjan
@@ -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/