1use crate::models::{Issue, Report, PageLoadResult, Severity, Category};
2use scraper::{Html, Selector};
3
4pub struct AccessibilityChecker;
6
7impl AccessibilityChecker {
8 pub fn new() -> Self {
10 Self
11 }
12
13 pub fn check_accessibility(&self, load_result: PageLoadResult) -> Report {
15 let mut report = Report::new(load_result.website_url.as_deref());
16
17 report.page_load_time = load_result.load_time;
19 report.request_count = load_result.request_count;
20 report.page_size = load_result.page_size;
21 report.uses_cdn = self.detect_cdn_usage(&load_result.document);
22 self.calculate_environmental_impact(&mut report);
23
24 report.issues.extend(self.check_alt_text(&load_result.document));
26 report.issues.extend(self.check_labels(&load_result.document));
27 report.issues.extend(self.check_title(&load_result.document));
28 report.issues.extend(self.check_heading_hierarchy(&load_result.document));
29 report.issues.extend(self.check_color_contrast(&load_result.document));
30 report.issues.extend(self.check_lang_attributes(&load_result.document));
31 report.issues.extend(self.check_best_practices(&load_result.document));
32 report.issues.extend(self.check_target_size(&load_result.document));
33 report.issues.extend(self.check_redundant_entry(&load_result.document));
34
35 report.issues.extend(self.check_performance(&load_result));
37 report.issues.extend(self.check_environment(&load_result.document, &load_result));
38
39 report.recalculate();
41
42 report
43 }
44
45 fn check_alt_text(&self, document: &Html) -> Vec<Issue> {
47 let mut issues = Vec::new();
48 let selector = Selector::parse("img").unwrap();
49
50 for img in document.select(&selector) {
51 let has_alt = img.value().attr("alt").is_some();
52 let alt_value = img.value().attr("alt").unwrap_or("");
53 let has_aria_label = img.value().attr("aria-label").is_some();
54 let has_aria_labelledby = img.value().attr("aria-labelledby").is_some();
55 let is_decorative = img.value().attr("role")
56 .map(|r| r == "presentation")
57 .unwrap_or(false);
58
59 if !has_alt && !has_aria_label && !has_aria_labelledby && !is_decorative {
60 issues.push(Issue::new(
61 "Missing Alt Text",
62 Some(&img.html()),
63 Some("Add alt attribute describing the image, or use alt='' for decorative images"),
64 Severity::Warning,
65 Some("<img src='image.jpg' alt='Description of image'>"),
66 Category::Accessibility,
67 ));
68 } else if has_alt && alt_value.trim().is_empty() && !is_decorative {
69 issues.push(Issue::new(
70 "Empty Alt Text",
71 Some(&img.html()),
72 Some("Provide meaningful alt text or use alt='' for decorative images"),
73 Severity::Warning,
74 Some("<img src='image.jpg' alt='Description of image'>"),
75 Category::Accessibility,
76 ));
77 }
78 }
79
80 issues
81 }
82
83 fn check_labels(&self, document: &Html) -> Vec<Issue> {
85 let mut issues = Vec::new();
86 let input_selector = Selector::parse("input, select, textarea").unwrap();
87
88 for input in document.select(&input_selector) {
89 let input_type = input.value().attr("type").unwrap_or("text");
90 let id = input.value().attr("id");
91
92 if matches!(input_type, "hidden" | "submit" | "button" | "image") {
94 continue;
95 }
96
97 let has_label = if let Some(id) = id {
98 let label_selector = format!("label[for=\"{}\"]", id);
99 document.select(&Selector::parse(&label_selector).unwrap()).next().is_some()
100 } else {
101 false
102 };
103
104 let has_aria_label = input.value().attr("aria-label").is_some();
105 let has_aria_labelledby = input.value().attr("aria-labelledby").is_some();
106 let is_wrapped_in_label = input.parent()
107 .and_then(|p| p.value().as_element())
108 .map(|e| e.name() == "label")
109 .unwrap_or(false);
110
111 if !has_label && !has_aria_label && !has_aria_labelledby && !is_wrapped_in_label {
112 issues.push(Issue::new(
113 "Missing Label",
114 Some(&input.html()),
115 Some("Add label with for attribute, aria-label, or wrap in label element"),
116 Severity::Warning,
117 Some("<label for='inputId'>Label text</label><input id='inputId' type='text'>"),
118 Category::Accessibility,
119 ));
120 }
121 }
122
123 issues
124 }
125
126 fn check_title(&self, document: &Html) -> Vec<Issue> {
128 let mut issues = Vec::new();
129 let title_selector = Selector::parse("title").unwrap();
130
131 if document.select(&title_selector).next().is_none() {
132 issues.push(Issue::new(
133 "Missing Title",
134 Some("<head>...</head>"),
135 Some("Add <title> tag in <head>"),
136 Severity::Error,
137 Some("<title>Page Title</title>"),
138 Category::Accessibility,
139 ));
140 }
141
142 issues
143 }
144
145 fn check_heading_hierarchy(&self, document: &Html) -> Vec<Issue> {
147 let mut issues = Vec::new();
148 let heading_selector = Selector::parse("h1, h2, h3, h4, h5, h6").unwrap();
149
150 let headings: Vec<_> = document.select(&heading_selector).collect();
151 if headings.len() <= 1 {
152 return issues;
153 }
154
155 let mut last_level = 0;
156 let mut skip_count = 0;
157
158 for heading in &headings {
159 let tag_name = heading.value().name();
160 let level = tag_name.chars().nth(1)
161 .and_then(|c| c.to_digit(10))
162 .unwrap_or(1) as i32;
163
164 if last_level > 0 && level > last_level + 1 {
165 skip_count += 1;
166 if skip_count <= 3 {
167 issues.push(Issue::new(
168 "Heading Hierarchy Skip",
169 Some(&heading.html()),
170 Some("Consider using intermediate heading levels for better structure"),
171 Severity::Info,
172 Some(&format!("Use h{} before jumping to h{}", last_level + 1, level)),
173 Category::Accessibility,
174 ));
175 }
176 }
177 last_level = level;
178 }
179
180 let has_h1 = headings.iter().any(|h| h.value().name() == "h1");
182 if !has_h1 {
183 issues.push(Issue::new(
184 "Missing H1 Heading",
185 Some("<body>"),
186 Some("Add an h1 element as the main page heading"),
187 Severity::Warning,
188 Some("<h1>Main Page Title</h1>"),
189 Category::Accessibility,
190 ));
191 }
192
193 issues
194 }
195
196 fn check_color_contrast(&self, document: &Html) -> Vec<Issue> {
198 let mut issues = Vec::new();
199 let style_selector = Selector::parse("[style]").unwrap();
200
201 for element in document.select(&style_selector) {
202 if let Some(style) = element.value().attr("style") {
203 if style.contains("color:") && style.contains("background-color:") {
205 issues.push(Issue::new(
206 "Color Contrast Check Needed",
207 Some(&element.html()),
208 Some("Verify text meets WCAG contrast requirements (4.5:1 minimum)"),
209 Severity::Info,
210 Some("Use a color contrast checker tool"),
211 Category::Accessibility,
212 ));
213 }
214 }
215 }
216
217 issues
218 }
219
220 fn check_lang_attributes(&self, document: &Html) -> Vec<Issue> {
222 let mut issues = Vec::new();
223 let html_selector = Selector::parse("html").unwrap();
224
225 if let Some(html) = document.select(&html_selector).next() {
226 if html.value().attr("lang").is_none() {
227 issues.push(Issue::new(
228 "Missing lang Attribute",
229 Some("<html>"),
230 Some("Add lang attribute to html tag"),
231 Severity::Warning,
232 Some("<html lang=\"en\">"),
233 Category::Accessibility,
234 ));
235 }
236 }
237
238 issues
239 }
240
241 fn check_best_practices(&self, document: &Html) -> Vec<Issue> {
243 let mut issues = Vec::new();
244 let favicon_selector = Selector::parse("link[rel*='icon']").unwrap();
245
246 if document.select(&favicon_selector).next().is_none() {
247 issues.push(Issue::new(
248 "Missing Favicon",
249 Some("<head>"),
250 Some("Add favicon link"),
251 Severity::Info,
252 Some("<link rel=\"icon\" href=\"favicon.ico\">"),
253 Category::BestPractices,
254 ));
255 }
256
257 issues
258 }
259
260 fn check_target_size(&self, document: &Html) -> Vec<Issue> {
262 let mut issues = Vec::new();
263 let target_selector = Selector::parse("a, button, input, select, textarea").unwrap();
264
265 let mut has_targets = false;
266 for target in document.select(&target_selector) {
267 let tag_name = target.value().name();
268 let input_type = target.value().attr("type").unwrap_or("");
269
270 if tag_name == "input" && input_type.eq_ignore_ascii_case("hidden") {
271 continue;
272 }
273 has_targets = true;
274 break;
275 }
276
277 if has_targets {
278 issues.push(Issue::new(
279 "Target Size Verification (WCAG 2.2)",
280 Some("Interactive Elements"),
281 Some("Ensure all clickable targets are at least 24x24 CSS pixels (WCAG 2.5.8 Target Size Minimum)"),
282 Severity::Info,
283 Some("Provide adequate padding or min-width/height in CSS"),
284 Category::Accessibility,
285 ));
286 }
287
288 issues
289 }
290
291 fn check_redundant_entry(&self, document: &Html) -> Vec<Issue> {
293 let mut issues = Vec::new();
294 let input_selector = Selector::parse("input").unwrap();
295
296 for input in document.select(&input_selector) {
297 let input_type = input.value().attr("type").unwrap_or("text").to_lowercase();
298
299 if input_type == "text" || input_type == "email" {
300 let name = input.value().attr("name").unwrap_or("").to_lowercase();
301 let id = input.value().attr("id").unwrap_or("").to_lowercase();
302 let has_autocomplete = input.value().attr("autocomplete").is_some();
303
304 if name.contains("name") || name.contains("email") || name.contains("phone") || name.contains("address") ||
305 id.contains("name") || id.contains("email") || id.contains("phone") || id.contains("address") {
306
307 if !has_autocomplete {
308 issues.push(Issue::new(
309 "Redundant Entry Risk (WCAG 2.2)",
310 Some(&input.html()),
311 Some("Add 'autocomplete' attribute to fields requesting user data (WCAG 3.3.7 Redundant Entry)"),
312 Severity::Warning,
313 Some("<input type=\"text\" name=\"email\" autocomplete=\"email\">"),
314 Category::Accessibility,
315 ));
316 }
317 }
318 }
319 }
320
321 issues
322 }
323
324 fn check_performance(&self, load_result: &PageLoadResult) -> Vec<Issue> {
326 let mut issues = Vec::new();
327
328 if load_result.load_time > 3.0 {
329 issues.push(Issue::new(
330 "Slow Page Load",
331 Some(&format!("Load time: {:.2}s", load_result.load_time)),
332 Some("Optimize images, minify assets, use caching"),
333 Severity::Warning,
334 Some("Use lazy loading, compress assets"),
335 Category::Performance,
336 ));
337 }
338
339 if load_result.request_count > 50 {
340 issues.push(Issue::new(
341 "High Request Count",
342 Some(&format!("Requests: {}", load_result.request_count)),
343 Some("Combine files, use sprites, reduce dependencies"),
344 Severity::Warning,
345 Some("Bundle CSS/JS files"),
346 Category::Performance,
347 ));
348 }
349
350 issues
351 }
352
353 fn check_environment(&self, _document: &Html, load_result: &PageLoadResult) -> Vec<Issue> {
355 let mut issues = Vec::new();
356
357 if load_result.page_size > 5 * 1024 * 1024 {
358 issues.push(Issue::new(
359 "Large Page Size",
360 Some(&format!("Size: {:.2} MB", load_result.page_size as f64 / (1024.0 * 1024.0))),
361 Some("Optimize images, minify assets, remove unused code"),
362 Severity::Warning,
363 Some("Compress images, use WebP format"),
364 Category::Environment,
365 ));
366 }
367
368 issues
369 }
370
371 fn detect_cdn_usage(&self, document: &Html) -> bool {
373 let script_selector = Selector::parse("script[src]").unwrap();
374 let link_selector = Selector::parse("link[href]").unwrap();
375
376 let cdn_domains = [
377 "cdn.jsdelivr.net",
378 "cdnjs.cloudflare.com",
379 "unpkg.com",
380 "ajax.googleapis.com",
381 "code.jquery.com",
382 "stackpath.bootstrapcdn.com",
383 "maxcdn.bootstrapcdn.com"
384 ];
385
386 for script in document.select(&script_selector) {
387 if let Some(src) = script.value().attr("src") {
388 if cdn_domains.iter().any(|domain| src.contains(domain)) {
389 return true;
390 }
391 }
392 }
393
394 for link in document.select(&link_selector) {
395 if let Some(href) = link.value().attr("href") {
396 if cdn_domains.iter().any(|domain| href.contains(domain)) {
397 return true;
398 }
399 }
400 }
401
402 false
403 }
404
405 fn calculate_environmental_impact(&self, report: &mut Report) {
407 let data_transfer_gb = report.page_size as f64 / (1024.0 * 1024.0 * 1024.0);
408 let base_energy = 0.01;
409 let data_energy = data_transfer_gb * 200.0;
410 let request_energy = report.request_count as f64 * 0.0001;
411
412 let mut total_energy = base_energy + data_energy + request_energy;
413
414 if report.uses_cdn {
415 total_energy *= 0.7; }
417
418 report.energy_consumption_kwh = total_energy;
419 report.co2_emissions_grams = total_energy * 500.0; report.environmental_rating = if report.co2_emissions_grams < 10.0 {
422 "Eco"
423 } else if report.co2_emissions_grams < 50.0 {
424 "Moderate"
425 } else {
426 "High Impact"
427 }.to_string();
428 }
429
430 pub fn export_to_text(&self, report: &Report) -> String {
432 let mut output = String::new();
433
434 output.push_str("Web Accessibility Report\n");
435 output.push_str(&format!("Website: {}\n", report.website_url.as_deref().unwrap_or("N/A")));
436 output.push_str(&format!("Total Issues: {}\n", report.total_issues));
437 output.push_str(&format!("Errors: {}, Warnings: {}, Info: {}\n",
438 report.error_count, report.warning_count, report.info_count));
439 output.push_str(&format!("Accessibility Score: {}/100\n", report.accessibility_score));
440 output.push_str(&format!("Compliance Status: {}\n", report.compliance_status));
441
442 if report.page_load_time > 0.0 {
443 output.push_str(&format!("Page Load Time: {:.2}s\n", report.page_load_time));
444 output.push_str(&format!("Request Count: {}\n", report.request_count));
445 output.push_str(&format!("Page Size: {:.2} MB\n", report.page_size as f64 / (1024.0 * 1024.0)));
446 output.push_str(&format!("Uses CDN: {}\n", if report.uses_cdn { "Yes" } else { "No" }));
447 output.push_str(&format!("Energy Consumption: {:.4} kWh\n", report.energy_consumption_kwh));
448 output.push_str(&format!("CO₂ Emissions: {:.2} grams\n", report.co2_emissions_grams));
449 output.push_str(&format!("Environmental Rating: {}\n", report.environmental_rating));
450 }
451
452 output.push_str("\nIssues:\n");
453 for issue in &report.issues {
454 output.push_str(&format!("\nCategory: {:?}\n", issue.category));
455 output.push_str(&format!("Type: {}\n", issue.issue_type));
456 output.push_str(&format!("Severity: {:?}\n", issue.severity));
457 if let Some(snippet) = &issue.element_snippet {
458 output.push_str(&format!("Element: {}\n", snippet));
459 }
460 if let Some(fix) = &issue.suggested_fix {
461 output.push_str(&format!("Fix: {}\n", fix));
462 }
463 if let Some(example) = &issue.fix_example {
464 output.push_str(&format!("Example: {}\n", example));
465 }
466 output.push_str("---\n");
467 }
468
469 output
470 }
471}
472
473impl Default for AccessibilityChecker {
474 fn default() -> Self {
475 Self::new()
476 }
477}