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