'skip_link', 'title' => __( 'Add a skip link', 'a11ymate' ), 'what' => __( 'Adds a "Skip to main content" link as the first thing keyboard users reach. It stays hidden until it gets focus. Only added if your theme does not already have one.', 'a11ymate' ), 'rules' => array( 'skip-link-missing' ), ), array( 'key' => 'pinch_zoom', 'title' => __( 'Restore pinch-zoom', 'a11ymate' ), 'what' => __( 'Removes user-scalable=no and maximum-scale from the viewport tag so people can zoom in on phones.', 'a11ymate' ), 'rules' => array( 'viewport-zoom-disabled' ), ), array( 'key' => 'focus_ring', 'title' => __( 'Show a clear focus ring', 'a11ymate' ), 'what' => __( 'Adds a two-tone :focus-visible outline in the colour below, with a white inner ring so it stands out on any background.', 'a11ymate' ), 'rules' => array( 'focus-not-visible' ), ), array( 'key' => 'new_tab_text', 'title' => __( 'Say when a link opens a new tab', 'a11ymate' ), 'what' => __( 'Adds "(opens in a new tab)" as screen reader text to links with target="_blank", and rel="noopener".', 'a11ymate' ), 'rules' => array( 'link-new-window' ), ), array( 'key' => 'iframe_titles', 'title' => __( 'Give embeds a title', 'a11ymate' ), 'what' => __( 'Adds a title to iframes that lack one, taken from the oEmbed provider or the embed address (YouTube, Vimeo, Google Maps and so on).', 'a11ymate' ), 'rules' => array( 'iframe-title-missing' ), ), array( 'key' => 'reduced_motion', 'title' => __( 'Respect "reduce motion"', 'a11ymate' ), 'what' => __( 'For visitors whose device asks for less motion, switches off animations, transitions and smooth scrolling.', 'a11ymate' ), 'rules' => array( 'motion-reduced-ignored' ), ), array( 'key' => 'underline_links', 'title' => __( 'Underline links in content', 'a11ymate' ), 'what' => __( 'Underlines links inside paragraphs and lists, so they do not rely on colour alone. Buttons are left alone.', 'a11ymate' ), 'rules' => array( 'link-colour-only' ), ), array( 'key' => 'lang', 'title' => __( 'Add a missing page language', 'a11ymate' ), 'what' => __( 'Adds lang="…" from your Site Language setting if the theme leaves it off the tag.', 'a11ymate' ), 'rules' => array( 'html-lang-missing' ), ), array( 'key' => 'tabindex', 'title' => __( 'Remove positive tabindex', 'a11ymate' ), 'what' => __( 'Strips tabindex values above 0 so the tab order follows the page. (Elements keep their natural order.)', 'a11ymate' ), 'rules' => array( 'tabindex-positive' ), ), array( 'key' => 'alt_from_library', 'title' => __( 'Fill empty alt text from the Media Library', 'a11ymate' ), 'what' => __( 'When an image in your content has an empty alt but the Media Library has a description for it, uses that description when the page is shown. Images marked as decorative stay empty. Your posts are not changed.', 'a11ymate' ), 'rules' => array( 'img-alt-missing' ), ), ); } /** * Hooks. * * @return void */ public function register() { if ( is_admin() ) { return; } $this->fx = array_filter( (array) Settings::get( 'safe_fixes', array() ) ); if ( ! $this->fx ) { return; } if ( ! empty( $this->fx['iframe_titles'] ) ) { add_filter( 'embed_oembed_html', array( $this, 'oembed_title' ), 20, 4 ); } if ( ! empty( $this->fx['alt_from_library'] ) ) { add_filter( 'the_content', array( $this, 'alt_from_library' ), 20 ); } add_action( 'wp_enqueue_scripts', array( $this, 'head_css' ), 98 ); add_action( 'template_redirect', array( $this, 'start_buffer' ), 1 ); } /** * CSS-only fixes, added as an inline style on an empty registered handle so WordPress prints them. * * @return void */ public function head_css() { $css = ''; if ( ! empty( $this->fx['focus_ring'] ) ) { $c = sanitize_hex_color( $this->fx['focus_colour'] ) ? $this->fx['focus_colour'] : '#1F5C8B'; $css .= ":focus-visible{outline:3px solid {$c} !important;outline-offset:2px !important;box-shadow:0 0 0 2px #fff !important}"; } if ( ! empty( $this->fx['reduced_motion'] ) ) { $css .= '@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important;scroll-behavior:auto !important}}'; } if ( ! empty( $this->fx['underline_links'] ) ) { $css .= 'main p a:not(.wp-element-button):not(.wp-block-button__link),main li a:not(.wp-element-button):not(.wp-block-button__link),.entry-content p a,.entry-content li a,.wp-block-post-content p a,.wp-block-post-content li a{text-decoration:underline !important;text-underline-offset:.15em}'; } if ( ! empty( $this->fx['skip_link'] ) ) { $css .= '.a11ymate-skip{position:absolute;left:-9999px;top:0;z-index:100000;background:#13304A;color:#fff;padding:.75rem 1rem;font:600 1rem/1.2 system-ui,sans-serif;text-decoration:none;border-radius:0 0 6px 0}.a11ymate-skip:focus{left:0}'; } if ( ! empty( $this->fx['new_tab_text'] ) ) { $css .= '.a11ymate-sr{position:absolute !important;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}'; } if ( '' !== $css ) { wp_register_style( 'a11ymate-safe-fixes', false, array(), A11YMATE_VERSION ); wp_enqueue_style( 'a11ymate-safe-fixes' ); wp_add_inline_style( 'a11ymate-safe-fixes', $css ); } } /** * Start output buffering for the HTML-level fixes. * * @return void */ public function start_buffer() { $needs = array( 'skip_link', 'pinch_zoom', 'new_tab_text', 'iframe_titles', 'lang', 'tabindex' ); $any = false; foreach ( $needs as $k ) { if ( ! empty( $this->fx[ $k ] ) ) { $any = true; } } if ( ! $any || is_feed() || is_embed() || is_robots() || wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || is_customize_preview() ) { return; } ob_start( array( $this, 'filter_html' ) ); } /** * Apply fixes to the finished page. * * @param string $html HTML. * @return string */ public function filter_html( $html ) { if ( strlen( $html ) < 200 || false === stripos( $html, '||)#is', $html, -1, PREG_SPLIT_DELIM_CAPTURE ); if ( ! is_array( $parts ) ) { return $html; // A regex limit was hit: serve the page untouched rather than break it. } foreach ( $parts as $i => $part ) { if ( 1 === $i % 2 ) { continue; // Script, style or comment. } $parts[ $i ] = $this->fix_segment( $part ); } $html = implode( '', $parts ); if ( ! empty( $this->fx['skip_link'] ) ) { $html = $this->add_skip_link( $html ); } return $html; } /** * Fix one chunk of markup. * * @param string $s Markup. * @return string */ private function fix_segment( $s ) { $orig = $s; if ( ! is_string( $s ) ) { return $orig; } if ( ! empty( $this->fx['lang'] ) ) { $s = preg_replace_callback( '/]*)>/i', function ( $m ) { if ( preg_match( '/\blang\s*=/i', $m[1] ) ) { return $m[0]; } return ''; }, $s, 1 ); } if ( ! is_string( $s ) ) { return $orig; } if ( ! empty( $this->fx['pinch_zoom'] ) ) { $s = preg_replace_callback( '/]*name=["\']viewport["\'][^>]*>/i', static function ( $m ) { $tag = preg_replace( '/,?\s*(user-scalable|maximum-scale)\s*=\s*[^,"\']*/i', '', $m[0] ); return preg_replace( '/content=(["\'])\s*,\s*/i', 'content=$1', $tag ); }, $s ); } if ( ! is_string( $s ) ) { return $orig; } if ( ! empty( $this->fx['tabindex'] ) ) { $s = preg_replace( '/(<[a-z][^>]*?)\s+tabindex=(["\'])[1-9]\d*\2/i', '$1', $s ); } if ( ! is_string( $s ) ) { return $orig; } if ( ! empty( $this->fx['iframe_titles'] ) ) { $s = preg_replace_callback( '/]*)>/i', function ( $m ) { if ( preg_match( '/\btitle\s*=\s*(["\'])[^"\']+\1/i', $m[1] ) || ! preg_match( '/\bsrc\s*=\s*(["\'])(.*?)\1/i', $m[1], $src ) ) { return $m[0]; } return '