PluginProbe ʕ •ᴥ•ʔ
Web Accessibility Toolkit – Accessibility Checker & ARIA for WCAG, Section 508 & ADA Compliance / 1.7.0
Web Accessibility Toolkit – Accessibility Checker & ARIA for WCAG, Section 508 & ADA Compliance v1.7.0
1.7.0 trunk 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6
aria-accessibility-toolkit / frontend / class-frontend.php
aria-accessibility-toolkit / frontend Last commit date
class-frontend-checker.php 1 month ago class-frontend.php 1 month ago index.php 1 year ago
class-frontend.php
537 lines
1 <?php
2
3 // Exit if accessed directly
4 if ( ! defined( 'ABSPATH' ) ) exit;
5
6 class ARIAAT_Helper_Frontend {
7
8 private $general_settings;
9
10 /**
11 * Constructor to hook the necessary actions.
12 */
13 public function __construct() {
14
15 // aria labels & roles via PHP
16 add_filter('the_content', [$this, 'inject_content']);
17
18 // aria labels & roles via JS (fallback)
19 add_action('wp_enqueue_scripts', [$this, 'enqueue_scripts']);
20
21 // contrast & skip link
22 add_action('wp_head', [$this, 'output_inline_styles']);
23
24 // Main nav role injection
25 add_action('wp_loaded', [$this, 'init_nav_nav_role_attribute']);
26 add_action('wp_loaded', [$this, 'init_nav_aria_labels']);
27
28 $this->general_settings = get_option('ariaat_general_settings', []);
29
30 add_action('wp_body_open', [$this, 'output_skip_link']);
31
32 add_filter('language_attributes', [$this, 'filter_language_attribute']);
33
34 add_action('template_redirect', [$this, 'maybe_make_viewport_scalable']);
35
36 add_action('wp_ajax_ariaat_save_alt', [$this, 'ariaat_save_alt_callback']);
37
38 }
39
40 public function ariaat_save_alt_callback() {
41 // Basic checks
42 if (!current_user_can('upload_files')) {
43 wp_send_json_error('Permission denied');
44 }
45
46 $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
47 $alt = isset($_POST['alt']) ? sanitize_text_field($_POST['alt']) : '';
48 $nonce = isset($_POST['nonce']) ? sanitize_text_field($_POST['nonce']) : '';
49
50 if (!$id || !wp_verify_nonce($nonce, "ariaat_update_alt_{$id}")) {
51 wp_send_json_error('Invalid request');
52 }
53
54 if ('attachment' !== get_post_type($id)) {
55 wp_send_json_error('Invalid attachment');
56 }
57
58 // Free tier: hard cap of 10 unique image alt updates.
59 if (! defined('ARIAAT_PRO_ACTIVE')) {
60 $free_limit = 10;
61 $tracked_ids = get_option('ariaat_free_alt_updated_ids', []);
62
63 if (! is_array($tracked_ids)) {
64 $tracked_ids = [];
65 }
66
67 $tracked_ids = array_values(array_unique(array_map('intval', $tracked_ids)));
68 $current_id = (int) $id;
69 $has_used_slot = in_array($current_id, $tracked_ids, true);
70 $is_non_empty_alt = '' !== trim((string) $alt);
71
72 if ($is_non_empty_alt && ! $has_used_slot && count($tracked_ids) >= $free_limit) {
73 wp_send_json_error(sprintf('Free version allows alt text updates for up to %d images. Unlock to edit more.', $free_limit));
74 }
75
76 if ($is_non_empty_alt && ! $has_used_slot) {
77 $tracked_ids[] = $current_id;
78 update_option('ariaat_free_alt_updated_ids', $tracked_ids, false);
79 }
80 }
81
82 // Save alt text
83 update_post_meta($id, '_wp_attachment_image_alt', $alt);
84
85 wp_send_json_success('Alt text updated');
86 }
87
88
89 public function enqueue_scripts() {
90 $aria = get_option('ariaat_aria_mappings');
91 $roles = get_option('ariaat_role_mappings');
92 $general = get_option('ariaat_general_settings');
93
94 $has_skip_link_fix = ! empty( trim($general['skip_link'] ?? '') );
95 $has_tabindex_fix = ! empty( trim($general['fix_tabindex'] ?? '') );
96 $has_non_text_fallback_fix = ! empty( trim($general['fix_non_text_fallback'] ?? '') );
97
98 // Exit early if nothing is enabled
99 if (empty($aria) && empty($roles) && ! $has_skip_link_fix && ! $has_tabindex_fix && ! $has_non_text_fallback_fix ) {
100 return;
101 }
102
103 $v = ARIAATVERSION;
104 //$v = time(); // For development, remove in production
105
106 wp_enqueue_script('ariaat-frontend', ARIAATURL . 'assets/js/ariaat-frontend.js', [], $v, true);
107
108 wp_localize_script('ariaat-frontend', 'Ariaat_Data', [
109 'aria' => is_array( $aria ) ? array_values( $aria ) : [],
110 'roles' => is_array( $roles ) ? array_values( $roles ) : [],
111 'skip_link' => trim($general['skip_link'] ?? ''),
112 'fix_tabindex' => trim($general['fix_tabindex'] ?? ''),
113 'fix_non_text_fallback' => trim($general['fix_non_text_fallback'] ?? ''),
114 'make_viewport_scalable' => trim($general['make_viewport_scalable'] ?? '')
115 ]);
116 }
117
118 public function maybe_make_viewport_scalable() {
119 $settings = get_option('ariaat_general_settings', []);
120 if (!empty($settings['make_viewport_scalable'])) {
121 ob_start([$this, 'remove_user_scalable']);
122 }
123 }
124
125 public function remove_user_scalable( $html ) {
126 return preg_replace_callback(
127 '/<meta\s+name=["\']viewport["\']\s+content=["\']([^"\']+)["\']\s*\/?>/i',
128 function ( $matches ) {
129 $content = $matches[1];
130
131 // Remove user-scalable=no
132 $content = preg_replace(
133 '/\s*user-scalable\s*=\s*no\s*,?/i',
134 '',
135 $content
136 );
137
138 // Remove any maximum-scale setting (e.g. 1, 1.0, 3, etc.)
139 $content = preg_replace(
140 '/\s*maximum-scale\s*=\s*[\d.]+\s*,?/i',
141 '',
142 $content
143 );
144
145 // Normalise commas / whitespace after removals
146 $content = trim($content, " \t\n\r\0\x0B,");
147 $content = preg_replace('/\s*,\s*/', ', ', $content);
148
149 return '<meta name="viewport" content="' . esc_attr( $content ) . '">';
150 },
151 $html
152 );
153 }
154
155
156
157 public function output_skip_link() {
158 $settings = get_option('ariaat_general_settings', []);
159 $selector = trim($settings['skip_link'] ?? '');
160
161 if (!empty($selector)) {
162 $is_id = strpos($selector, '#') === 0;
163
164 // If it's already an ID selector, link directly to it.
165 $target = $is_id ? $selector : '#ariaat-main-content';
166
167 echo '<a href="' . esc_attr($target) . '" class="ariaat-skip-link">Skip to content</a>';
168 }
169 }
170
171
172 public function filter_language_attribute($output) {
173 $this->general_settings = get_option('ariaat_general_settings', []);
174
175 if (!empty($this->general_settings['language'])) {
176 $lang = esc_attr($this->general_settings['language']);
177 $output = preg_replace('/lang="[^"]*"/', '', $output); // remove existing lang if present
178 $output .= ' lang="' . $lang . '"';
179 }
180
181 return $output;
182 }
183
184
185
186 public function init_nav_nav_role_attribute() {
187 add_filter('wp_nav_menu', [$this, 'inject_nav_role_attribute'], 99, 2);
188 }
189
190 public function inject_nav_role_attribute($nav_menu, $args) {
191 $selected_menus = get_option('ariaat_role_menus');
192 $selected_menus = ! $selected_menus ? [] : $selected_menus;
193 $menu_obj = null;
194
195 if (!empty($args->menu)) {
196 $menu_obj = wp_get_nav_menu_object($args->menu);
197 }
198
199 if (!$menu_obj && !empty($args->theme_location)) {
200 $locations = get_nav_menu_locations();
201 if (isset($locations[$args->theme_location])) {
202 $menu_obj = wp_get_nav_menu_object($locations[$args->theme_location]);
203 }
204 }
205
206 if (!$menu_obj || !in_array($menu_obj->term_id, $selected_menus)) {
207 return $nav_menu;
208 }
209
210 // Do not add if role already exists
211 if (strpos($nav_menu, 'role="navigation"') !== false) {
212 return $nav_menu;
213 }
214
215 // Only add to nav, div, or section — not ul, li, etc.
216 $nav_menu = preg_replace_callback(
217 '/<(nav|div|section)([^>]*)>/i',
218 function ($matches) {
219 $tag = $matches[1];
220 $attrs = $matches[2];
221
222 // Prevent duplicate role attribute
223 if (strpos($attrs, 'role=') !== false) {
224 return "<$tag$attrs>";
225 }
226
227 return "<$tag role=\"navigation\"$attrs>";
228 },
229 $nav_menu,
230 1 // Only replace the first matching container
231 );
232
233 return $nav_menu;
234 }
235
236 public function init_nav_aria_labels() {
237 add_filter('nav_menu_link_attributes', [$this, 'inject_aria_label_on_menu_item'], 10, 4);
238 // Fallback for themes/walkers that ignore $atts (e.g., Bootscore/Bootstrap walkers)
239 add_filter('walker_nav_menu_start_el', [$this, 'force_aria_label_in_markup'], 20, 4);
240 }
241
242 /**
243 * Primary path: adjust link attributes when wp_nav_menu() runs.
244 */
245 public function inject_aria_label_on_menu_item($atts, $item, $args, $depth = 0) {
246 // 1) Only run for selected menus
247 $selected_menus = get_option('ariaat_aria_menus', []);
248 if (empty($selected_menus) || !is_array($selected_menus)) {
249 return $atts;
250 }
251 $selected_menus = array_map('intval', $selected_menus);
252
253 // Resolve menu id from args (menu -> theme_location -> item terms)
254 $menu_id = 0;
255 if (!empty($args->menu)) {
256 $menu_obj = wp_get_nav_menu_object($args->menu);
257 if ($menu_obj && !is_wp_error($menu_obj) && isset($menu_obj->term_id)) {
258 $menu_id = (int) $menu_obj->term_id;
259 }
260 }
261 if (!$menu_id && !empty($args->theme_location)) {
262 $locations = get_nav_menu_locations();
263 if (!empty($locations[$args->theme_location])) {
264 $menu_id = (int) $locations[$args->theme_location];
265 }
266 }
267 if (!$menu_id) {
268 $terms = wp_get_post_terms($item->ID, 'nav_menu', ['fields' => 'ids']);
269 if (!is_wp_error($terms) && !empty($terms)) {
270 $menu_id = (int) $terms[0];
271 }
272 }
273
274 if (!$menu_id || !in_array($menu_id, $selected_menus, true)) {
275 return $atts;
276 }
277
278 // 2) Prefer custom per-item ARIA label; fallback to Title Attribute if different to visible text
279 $custom = get_post_meta($item->ID, '_ariaat_aria_label', true);
280 $custom = is_string($custom) ? trim($custom) : '';
281 $link_text = trim(wp_strip_all_tags($item->title ?? ''));
282 $title_attr = '';
283 if (!empty($item->attr_title)) {
284 $title_attr = trim($item->attr_title);
285 } elseif (!empty($atts['title'])) {
286 $title_attr = trim($atts['title']);
287 }
288
289 if ($custom !== '') {
290 $atts['aria-label'] = $custom; // walker will escape
291 unset($atts['title']);
292 return $atts;
293 }
294
295 if ($title_attr !== '' && $title_attr !== $link_text) {
296 $atts['aria-label'] = $title_attr;
297 unset($atts['title']);
298 }
299
300 return $atts;
301 }
302
303 /**
304 * Final safeguard: enforce aria-label in the rendered markup (for custom walkers).
305 */
306 public function force_aria_label_in_markup($item_output, $item, $depth, $args) {
307 $selected_menus = get_option('ariaat_aria_menus', []);
308 if (empty($selected_menus) || !is_array($selected_menus)) {
309 return $item_output;
310 }
311 $selected_menus = array_map('intval', $selected_menus);
312
313 // Resolve menu id
314 $menu_id = 0;
315 if (!empty($args->menu)) {
316 $menu_obj = wp_get_nav_menu_object($args->menu);
317 if ($menu_obj && !is_wp_error($menu_obj) && isset($menu_obj->term_id)) {
318 $menu_id = (int) $menu_obj->term_id;
319 }
320 }
321 if (!$menu_id && !empty($args->theme_location)) {
322 $locations = get_nav_menu_locations();
323 if (!empty($locations[$args->theme_location])) {
324 $menu_id = (int) $locations[$args->theme_location];
325 }
326 }
327 if (!$menu_id) {
328 $terms = wp_get_post_terms($item->ID, 'nav_menu', ['fields' => 'ids']);
329 if (!is_wp_error($terms) && !empty($terms)) {
330 $menu_id = (int) $terms[0];
331 }
332 }
333 if (!$menu_id || !in_array($menu_id, $selected_menus, true)) {
334 return $item_output;
335 }
336
337 // If aria-label already present, just strip title to avoid duplicate tooltip
338 if (stripos($item_output, 'aria-label=') !== false) {
339 return preg_replace('/\s+title=("|\')(.*?)\1/i', '', $item_output);
340 }
341
342 // Candidate label: custom meta > title attribute (when different to visible text)
343 $custom = get_post_meta($item->ID, '_ariaat_aria_label', true);
344 $custom = is_string($custom) ? trim($custom) : '';
345 $link_text = trim(wp_strip_all_tags($item->title ?? ''));
346 $title_attr = '';
347 if (!empty($item->attr_title)) {
348 $title_attr = trim($item->attr_title);
349 }
350
351 $candidate = '';
352 if ($custom !== '') {
353 $candidate = $custom;
354 } elseif ($title_attr !== '' && $title_attr !== $link_text) {
355 $candidate = $title_attr;
356 }
357 if ($candidate === '') {
358 return $item_output;
359 }
360
361 // Remove existing title= and inject aria-label into first <a ...>
362 $item_output = preg_replace('/\s+title=("|\')(.*?)\1/i', '', $item_output);
363 $label_esc = esc_attr($candidate);
364 return preg_replace('/<a\s+/i', '<a aria-label="'.$label_esc.'" ', $item_output, 1);
365 }
366
367
368 /**
369 *
370 * This function modifies already-filtered post content (after KSES) and does not introduce unsanitized markup.
371 *
372 */
373 public function inject_content($content) {
374 libxml_use_internal_errors(true);
375
376 $ariaat_aria_mappings = get_option('ariaat_aria_mappings', []);
377 $ariaat_role_mappings = get_option('ariaat_role_mappings', []);
378
379 $dom = new \DOMDocument();
380 $wrapped = '<div id="ariaat-temp-wrapper">' . $content . '</div>';
381
382 // Safely inject ARIA and role attributes into already-filtered HTML content.
383 $dom->loadHTML('<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
384 $xpath = new \DOMXPath($dom);
385
386 // ARIA attributes
387 if($ariaat_aria_mappings) {
388 foreach ($ariaat_aria_mappings as $item) {
389 if (empty($item['selector']) || empty($item['attribute'])) continue;
390
391 $finalSelector = $this->strip_selector_context($item['selector']);
392 $query = $this->css_to_xpath('#ariaat-temp-wrapper ' . $finalSelector);
393 foreach ($xpath->query($query) as $el) {
394 if (!$el->hasAttribute($item['attribute'])) {
395 $el->setAttribute($item['attribute'], $item['value']);
396 }
397 }
398 }
399 }
400
401 // Roles
402 if($ariaat_role_mappings) {
403 foreach ($ariaat_role_mappings as $item) {
404 if (empty($item['selector']) || empty($item['role'])) continue;
405
406 $finalSelector = $this->strip_selector_context($item['selector']);
407 $query = $this->css_to_xpath('#ariaat-temp-wrapper ' . $finalSelector);
408 foreach ($xpath->query($query) as $el) {
409 if (!$el->hasAttribute('role')) {
410 $el->setAttribute('role', $item['role']);
411 }
412 }
413 }
414 }
415
416 $wrapper = $dom->getElementById('ariaat-temp-wrapper');
417 $newHtml = '';
418 foreach ($wrapper->childNodes as $child) {
419 $newHtml .= $dom->saveHTML($child);
420 }
421
422 return $newHtml;
423 }
424
425 private function strip_selector_context($selector) {
426 // Split selector by space, return only last part (e.g., from 'body.single .entry-content a' get 'a')
427 $parts = preg_split('/\s+/', trim($selector));
428 return end($parts);
429 }
430
431
432 private function css_to_xpath($selector) {
433 $parts = preg_split('/\s+/', trim($selector));
434 $xpathParts = [];
435
436 foreach ($parts as $part) {
437 $tag = '*';
438 $conditions = [];
439
440 if (preg_match('/^([a-z0-9\-_]+)/i', $part, $m)) {
441 $tag = $m[1];
442 }
443
444 if (preg_match('/#([a-z0-9\-_]+)/i', $part, $m)) {
445 $conditions[] = "@id='{$m[1]}'";
446 }
447
448 if (preg_match_all('/\.([a-z0-9\-_]+)/i', $part, $matches)) {
449 foreach ($matches[1] as $class) {
450 $conditions[] = "contains(concat(' ', normalize-space(@class), ' '), ' {$class} ')";
451 }
452 }
453
454 $conditionString = $conditions ? '[' . implode(' and ', $conditions) . ']' : '';
455 $xpathParts[] = "{$tag}{$conditionString}";
456 }
457
458 return '//' . implode('//', $xpathParts);
459 }
460
461
462 public function output_inline_styles() {
463
464 $contrast = get_option('ariaat_contrast_mappings', []);
465 $settings = get_option('ariaat_general_settings', []);
466 $forms = get_option('ariaat_form_settings', []);
467
468 if (empty($contrast) && empty($settings['skip_link']) && empty($settings['focus_outline']) && empty($forms['group_form_fields'])) {
469 return;
470 }
471
472 echo "<style id='ariaat-inline-styles'>\n";
473
474 // Output contrast styles
475 foreach ($contrast as $item) {
476 if (empty($item['selector'])) {
477 continue;
478 }
479
480 $selector = trim($item['selector']);
481
482 // remove any HTML tags and limit dangerous characters
483 $selector = wp_strip_all_tags($selector);
484 $selector = preg_replace('/[^\w\s.#:>\[\]=\"~\+\-\*(),]/', '', $selector); // Allow basic CSS selector chars
485
486 $rules = [];
487
488 if (!empty($item['color']) && preg_match('/^#[a-f0-9]{3,6}$/i', $item['color'])) {
489 $rules[] = 'color: ' . esc_attr($item['color']);
490 }
491
492 if (!empty($item['background']) && preg_match('/^#[a-f0-9]{3,6}$/i', $item['background'])) {
493 $rules[] = 'background-color: ' . esc_attr($item['background']);
494 }
495
496 if (!empty($rules) && !empty($selector)) {
497 echo esc_html($selector) . ' { ' . esc_html(implode('; ', $rules)) . "; }\n";
498 }
499 }
500
501 // Skip link styles
502 if (!empty($settings['skip_link'])) {
503 echo ".ariaat-skip-link {\n";
504 echo " position: absolute;\n";
505 echo " top: -1000px;\n";
506 echo " left: 0;\n";
507 echo " background: #000;\n";
508 echo " color: #fff;\n";
509 echo " padding: 8px 12px;\n";
510 echo " z-index: 9999;\n";
511 echo " text-decoration: none;\n";
512 echo "}\n";
513 echo ".ariaat-skip-link:focus {\n";
514 echo " top: 10px;\n";
515 echo " left: 10px;\n";
516 echo "}\n";
517 }
518
519 // Focus outline style
520 if (!empty($settings['focus_outline'])) {
521 echo "*:focus { outline: 2px solid #005fcc !important; outline-offset: 2px; }\n";
522 }
523
524 do_action('ariaat_output_inline_styles');
525
526 echo "</style>\n";
527 }
528
529
530
531
532
533 }
534
535 // Initialize the class
536 new ARIAAT_Helper_Frontend();
537