PluginProbe ʕ •ᴥ•ʔ
Web Accessibility Toolkit – Accessibility Checker & ARIA for WCAG, Section 508 & ADA Compliance / 1.5.1
Web Accessibility Toolkit – Accessibility Checker & ARIA for WCAG, Section 508 & ADA Compliance v1.5.1
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 10 months ago class-frontend.php 10 months ago index.php 11 months ago
class-frontend.php
398 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 ]);
86 }
87
88 public function maybe_make_viewport_scalable() {
89 $settings = get_option('ariaat_general_settings', []);
90 if (!empty($settings['make_viewport_scalable'])) {
91 ob_start([$this, 'remove_user_scalable']);
92 }
93 }
94
95 public function remove_user_scalable($html) {
96 return preg_replace_callback('/<meta\s+name=["\']viewport["\']\s+content=["\']([^"\']+)["\']\s*\/?>/i', function ($matches) {
97 $content = $matches[1];
98 $content = preg_replace('/user-scalable\s*=\s*no\s*,?\s*/i', '', $content);
99 return '<meta name="viewport" content="' . esc_attr(trim($content)) . '">';
100 }, $html);
101 }
102
103
104 public function output_skip_link() {
105 $settings = get_option('ariaat_general_settings', []);
106 $selector = trim($settings['skip_link'] ?? '');
107
108 if (!empty($selector)) {
109 $is_id = strpos($selector, '#') === 0;
110
111 // If it's already an ID selector, link directly to it.
112 $target = $is_id ? $selector : '#ariaat-main-content';
113
114 echo '<a href="' . esc_attr($target) . '" class="ariaat-skip-link">Skip to content</a>';
115 }
116 }
117
118
119 public function filter_language_attribute($output) {
120 $this->general_settings = get_option('ariaat_general_settings', []);
121
122 if (!empty($this->general_settings['language'])) {
123 $lang = esc_attr($this->general_settings['language']);
124 $output = preg_replace('/lang="[^"]*"/', '', $output); // remove existing lang if present
125 $output .= ' lang="' . $lang . '"';
126 }
127
128 return $output;
129 }
130
131
132
133 public function init_nav_nav_role_attribute() {
134 add_filter('wp_nav_menu', [$this, 'inject_nav_role_attribute'], 99, 2);
135 }
136
137 public function inject_nav_role_attribute($nav_menu, $args) {
138 $selected_menus = get_option('ariaat_role_menus');
139 $selected_menus = ! $selected_menus ? [] : $selected_menus;
140 $menu_obj = null;
141
142 if (!empty($args->menu)) {
143 $menu_obj = wp_get_nav_menu_object($args->menu);
144 }
145
146 if (!$menu_obj && !empty($args->theme_location)) {
147 $locations = get_nav_menu_locations();
148 if (isset($locations[$args->theme_location])) {
149 $menu_obj = wp_get_nav_menu_object($locations[$args->theme_location]);
150 }
151 }
152
153 if (!$menu_obj || !in_array($menu_obj->term_id, $selected_menus)) {
154 return $nav_menu;
155 }
156
157 // Do not add if role already exists
158 if (strpos($nav_menu, 'role="navigation"') !== false) {
159 return $nav_menu;
160 }
161
162 // Only add to nav, div, or section — not ul, li, etc.
163 $nav_menu = preg_replace_callback(
164 '/<(nav|div|section)([^>]*)>/i',
165 function ($matches) {
166 $tag = $matches[1];
167 $attrs = $matches[2];
168
169 // Prevent duplicate role attribute
170 if (strpos($attrs, 'role=') !== false) {
171 return "<$tag$attrs>";
172 }
173
174 return "<$tag role=\"navigation\"$attrs>";
175 },
176 $nav_menu,
177 1 // Only replace the first matching container
178 );
179
180 return $nav_menu;
181 }
182
183 public function init_nav_aria_labels() {
184 add_filter('nav_menu_link_attributes', [$this, 'inject_aria_label_on_menu_item'], 10, 4);
185 }
186
187 public function inject_aria_label_on_menu_item($atts, $item, $args, $depth = 0) {
188 $selected_menus = get_option('ariaat_aria_menus', []);
189 if (empty($selected_menus) || !is_array($selected_menus)) {
190 return $atts;
191 }
192
193 // Get the menu object
194 $menu_id = 0;
195
196 // Try resolving from $args->menu
197 if (!empty($args->menu)) {
198 $menu_obj = wp_get_nav_menu_object($args->menu);
199 if ($menu_obj && isset($menu_obj->term_id)) {
200 $menu_id = $menu_obj->term_id;
201 }
202 }
203
204 // Try resolving from $args->theme_location
205 if (!$menu_id && !empty($args->theme_location)) {
206 $locations = get_nav_menu_locations();
207 if (isset($locations[$args->theme_location])) {
208 $menu_id = $locations[$args->theme_location];
209 }
210 }
211
212 // Skip if this menu isn't selected
213 if (!$menu_id || !in_array($menu_id, $selected_menus)) {
214 return $atts;
215 }
216
217 // If title is set and different from the link text, add aria-label
218 $link_text = trim(wp_strip_all_tags($item->title ?? ''));
219 $title_attr = trim($atts['title'] ?? '');
220
221 if ($title_attr && $title_attr !== $link_text) {
222 $atts['aria-label'] = esc_attr($title_attr);
223 unset($atts['title']);
224 }
225
226 return $atts;
227 }
228
229 /**
230 *
231 * This function modifies already-filtered post content (after KSES) and does not introduce unsanitized markup.
232 *
233 */
234 public function inject_content($content) {
235 libxml_use_internal_errors(true);
236
237 $ariaat_aria_mappings = get_option('ariaat_aria_mappings', []);
238 $ariaat_role_mappings = get_option('ariaat_role_mappings', []);
239
240 $dom = new \DOMDocument();
241 $wrapped = '<div id="ariaat-temp-wrapper">' . $content . '</div>';
242
243 // Safely inject ARIA and role attributes into already-filtered HTML content.
244 $dom->loadHTML('<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
245 $xpath = new \DOMXPath($dom);
246
247 // ARIA attributes
248 if($ariaat_aria_mappings) {
249 foreach ($ariaat_aria_mappings as $item) {
250 if (empty($item['selector']) || empty($item['attribute'])) continue;
251
252 $finalSelector = $this->strip_selector_context($item['selector']);
253 $query = $this->css_to_xpath('#ariaat-temp-wrapper ' . $finalSelector);
254 foreach ($xpath->query($query) as $el) {
255 if (!$el->hasAttribute($item['attribute'])) {
256 $el->setAttribute($item['attribute'], $item['value']);
257 }
258 }
259 }
260 }
261
262 // Roles
263 if($ariaat_role_mappings) {
264 foreach ($ariaat_role_mappings as $item) {
265 if (empty($item['selector']) || empty($item['role'])) continue;
266
267 $finalSelector = $this->strip_selector_context($item['selector']);
268 $query = $this->css_to_xpath('#ariaat-temp-wrapper ' . $finalSelector);
269 foreach ($xpath->query($query) as $el) {
270 if (!$el->hasAttribute('role')) {
271 $el->setAttribute('role', $item['role']);
272 }
273 }
274 }
275 }
276
277 $wrapper = $dom->getElementById('ariaat-temp-wrapper');
278 $newHtml = '';
279 foreach ($wrapper->childNodes as $child) {
280 $newHtml .= $dom->saveHTML($child);
281 }
282
283 return $newHtml;
284 }
285
286 private function strip_selector_context($selector) {
287 // Split selector by space, return only last part (e.g., from 'body.single .entry-content a' get 'a')
288 $parts = preg_split('/\s+/', trim($selector));
289 return end($parts);
290 }
291
292
293 private function css_to_xpath($selector) {
294 $parts = preg_split('/\s+/', trim($selector));
295 $xpathParts = [];
296
297 foreach ($parts as $part) {
298 $tag = '*';
299 $conditions = [];
300
301 if (preg_match('/^([a-z0-9\-_]+)/i', $part, $m)) {
302 $tag = $m[1];
303 }
304
305 if (preg_match('/#([a-z0-9\-_]+)/i', $part, $m)) {
306 $conditions[] = "@id='{$m[1]}'";
307 }
308
309 if (preg_match_all('/\.([a-z0-9\-_]+)/i', $part, $matches)) {
310 foreach ($matches[1] as $class) {
311 $conditions[] = "contains(concat(' ', normalize-space(@class), ' '), ' {$class} ')";
312 }
313 }
314
315 $conditionString = $conditions ? '[' . implode(' and ', $conditions) . ']' : '';
316 $xpathParts[] = "{$tag}{$conditionString}";
317 }
318
319 return '//' . implode('//', $xpathParts);
320 }
321
322
323 public function output_inline_styles() {
324
325 $contrast = get_option('ariaat_contrast_mappings', []);
326 $settings = get_option('ariaat_general_settings', []);
327 $forms = get_option('ariaat_form_settings', []);
328
329 if (empty($contrast) && empty($settings['skip_link']) && empty($settings['focus_outline']) && empty($forms['group_form_fields'])) {
330 return;
331 }
332
333 echo "<style id='ariaat-inline-styles'>\n";
334
335 // Output contrast styles
336 foreach ($contrast as $item) {
337 if (empty($item['selector'])) {
338 continue;
339 }
340
341 $selector = trim($item['selector']);
342
343 // remove any HTML tags and limit dangerous characters
344 $selector = wp_strip_all_tags($selector);
345 $selector = preg_replace('/[^\w\s.#:>\[\]=\"~\+\-\*(),]/', '', $selector); // Allow basic CSS selector chars
346
347 $rules = [];
348
349 if (!empty($item['color']) && preg_match('/^#[a-f0-9]{3,6}$/i', $item['color'])) {
350 $rules[] = 'color: ' . esc_attr($item['color']);
351 }
352
353 if (!empty($item['background']) && preg_match('/^#[a-f0-9]{3,6}$/i', $item['background'])) {
354 $rules[] = 'background-color: ' . esc_attr($item['background']);
355 }
356
357 if (!empty($rules) && !empty($selector)) {
358 echo esc_html($selector) . ' { ' . esc_html(implode('; ', $rules)) . "; }\n";
359 }
360 }
361
362 // Skip link styles
363 if (!empty($settings['skip_link'])) {
364 echo ".ariaat-skip-link {\n";
365 echo " position: absolute;\n";
366 echo " top: -1000px;\n";
367 echo " left: 0;\n";
368 echo " background: #000;\n";
369 echo " color: #fff;\n";
370 echo " padding: 8px 12px;\n";
371 echo " z-index: 9999;\n";
372 echo " text-decoration: none;\n";
373 echo "}\n";
374 echo ".ariaat-skip-link:focus {\n";
375 echo " top: 10px;\n";
376 echo " left: 10px;\n";
377 echo "}\n";
378 }
379
380 // Focus outline style
381 if (!empty($settings['focus_outline'])) {
382 echo "*:focus { outline: 2px solid #005fcc !important; outline-offset: 2px; }\n";
383 }
384
385 do_action('ariaat_output_inline_styles');
386
387 echo "</style>\n";
388 }
389
390
391
392
393
394 }
395
396 // Initialize the class
397 new ARIAAT_Helper_Frontend();
398