*/ const IMPACT_WEIGHTS = array( 'critical' => 10, 'serious' => 7, 'moderate' => 3, 'minor' => 1, ); /** * Score multiplier for each conformance level. AAA is reported but not scored. * * @var array */ const LEVEL_WEIGHTS = array( 'A' => 1.2, 'AA' => 1.0, 'BP' => 0.3, 'AAA' => 0.0, ); /** * Server rule ids that axe-core also checks. When the browser scan has run * on a page, its result is used for these instead of the server check. * * @var string[] */ const AXE_EQUIVALENTS = array( 'html-has-lang', 'document-title', 'image-alt', 'frame-title', 'meta-viewport', 'label', 'link-name', 'button-name', 'tabindex', 'empty-heading', 'heading-order', 'page-has-heading-one' ); /** * Fixes Fixlight applies when the page is rendered. Each can be switched off. * * @return array Fix id => title, description, default, rules. */ public static function fixes() { return array( 'lang' => array( 'title' => __( 'Page language', 'fixlight' ), 'description' => __( 'Adds the site language to pages that do not declare one, so screen readers pronounce the text correctly. An existing language (for example from WPML or Polylang) is never changed.', 'fixlight' ), 'default' => true, 'rules' => array( 'html-has-lang' ), ), 'page_title' => array( 'title' => __( 'Missing page titles', 'fixlight' ), 'description' => __( 'Fills an empty or missing with the WordPress page title. The title is the first thing a screen reader announces.', 'fixlight' ), 'default' => true, 'rules' => array( 'document-title' ), ), 'skip_link' => array( 'title' => __( 'Skip to content link', 'fixlight' ), 'description' => __( 'Adds a "Skip to content" link that appears when a keyboard user presses Tab, so they do not have to tab through the whole menu on every page. Skipped when the theme already has one.', 'fixlight' ), 'default' => true, 'rules' => array( 'skip-link' ), ), 'focus_visible' => array( 'title' => __( 'Visible keyboard focus', 'fixlight' ), 'description' => __( 'Shows a clear outline around the link or button that has keyboard focus. Many themes remove it, which leaves keyboard users lost. Mouse clicks do not show the outline.', 'fixlight' ), 'default' => true, 'rules' => array(), ), 'img_alt' => array( 'title' => __( 'Image alt text from the media library', 'fixlight' ), 'description' => __( 'When an image in a post has no alt text but the same image has alt text in the media library, uses the library text. Tracking pixels and spacer images are marked as decorative so screen readers skip them.', 'fixlight' ), 'default' => true, 'rules' => array( 'image-alt' ), ), 'iframe_title' => array( 'title' => __( 'Titles for embedded content', 'fixlight' ), 'description' => __( 'Gives embedded videos, maps and other iframes a title such as "YouTube video", so screen reader users know what the frame contains.', 'fixlight' ), 'default' => true, 'rules' => array( 'frame-title' ), ), 'form_labels' => array( 'title' => __( 'Form field labels', 'fixlight' ), 'description' => __( 'When a form field has placeholder text but no label, uses the placeholder as its accessible label. Fields without a placeholder are listed for you to fix.', 'fixlight' ), 'default' => true, 'rules' => array( 'label' ), ), 'icon_labels' => array( 'title' => __( 'Labels for icon links and buttons', 'fixlight' ), 'description' => __( 'Names links and buttons that show only an icon, such as social media icons or a search button, using the link address or the icon name. Icons it cannot identify are listed for you to fix.', 'fixlight' ), 'default' => true, 'rules' => array( 'link-name', 'button-name' ), ), 'read_more' => array( 'title' => __( 'Descriptive "Read more" links', 'fixlight' ), 'description' => __( 'Adds the post title to "Read more", "Continue reading" and similar links that point to a post, so they make sense out of context ("Read more: Spring opening hours"). The visible text does not change.', 'fixlight' ), 'default' => true, 'rules' => array( 'link-ambiguous' ), ), 'tabindex' => array( 'title' => __( 'Keyboard order', 'fixlight' ), 'description' => __( 'Removes positive tabindex values, which make the Tab key jump around the page in an unexpected order.', 'fixlight' ), 'default' => true, 'rules' => array( 'tabindex' ), ), 'new_tab' => array( 'title' => __( 'New tab warnings', 'fixlight' ), 'description' => __( 'Tells screen reader users when a link opens in a new tab, and adds rel="noopener" to those links for security.', 'fixlight' ), 'default' => true, 'rules' => array( 'link-new-tab' ), ), 'pdf_links' => array( 'title' => __( 'PDF link warnings', 'fixlight' ), 'description' => __( 'Tells screen reader users when a link opens a PDF document instead of a web page.', 'fixlight' ), 'default' => true, 'rules' => array( 'link-pdf' ), ), 'zoom' => array( 'title' => __( 'Allow zoom on phones', 'fixlight' ), 'description' => __( 'Removes settings that stop visitors from zooming the page on a phone. People with low vision rely on zoom.', 'fixlight' ), 'default' => true, 'rules' => array( 'meta-viewport' ), ), 'media_controls' => array( 'title' => __( 'Controls on autoplaying video', 'fixlight' ), 'description' => __( 'Adds play/pause controls to videos and audio that start on their own, and mutes autoplaying video, so visitors can stop them.', 'fixlight' ), 'default' => true, 'rules' => array( 'media-autoplay' ), ), 'underline_links' => array( 'title' => __( 'Underline links in text', 'fixlight' ), 'description' => __( 'Underlines links inside paragraphs and lists, so people who cannot tell colours apart can still see them. Buttons and menus are not changed. Off by default because it changes how your theme looks.', 'fixlight' ), 'default' => false, 'rules' => array( 'link-in-text-block' ), ), 'theme_compat' => array( 'title' => __( 'Fixes for your theme and plugins', 'fixlight' ), 'description' => __( 'Fixes problems in themes and plugins whose markup Fixlight knows: Divi, Extra, Bloom and Monarch so far. For example, it makes Divi\'s mobile menu, search icon and toggles work with the keyboard, opens submenus on keyboard focus and names social icons. It runs only when one of them is active.', 'fixlight' ), 'default' => true, 'rules' => array( 'mouse-only-control' ), ), 'reduced_motion' => array( 'title' => __( 'Respect "reduce motion"', 'fixlight' ), 'description' => __( 'For visitors who turned on "reduce motion" in their phone or computer settings, stops animations and smooth scrolling that can cause dizziness.', 'fixlight' ), 'default' => true, 'rules' => array(), ), ); } /** * Checks the PHP scanner runs. Browser (axe) results bring their own titles. * * @return array<string,array> Rule id => title, wcag, level, impact, why, how. */ public static function rules() { return array( 'html-has-lang' => array( 'title' => __( 'Page language is not set', 'fixlight' ), 'wcag' => '3.1.1', 'level' => 'A', 'impact' => 'serious', 'why' => __( 'Screen readers use the page language to pick the right voice and pronunciation. Without it, a Spanish page may be read with English pronunciation.', 'fixlight' ), 'how' => __( 'Fixlight fixes this automatically. Themes should print language_attributes() in the <html> tag.', 'fixlight' ), ), 'document-title' => array( 'title' => __( 'Page has no title', 'fixlight' ), 'wcag' => '2.4.2', 'level' => 'A', 'impact' => 'serious', 'why' => __( 'The title is announced first and shown in browser tabs and search results. Without it, people cannot tell pages apart.', 'fixlight' ), 'how' => __( 'Fixlight fixes this automatically. Themes should declare add_theme_support( \'title-tag\' ).', 'fixlight' ), ), 'skip-link' => array( 'title' => __( 'No "Skip to content" link', 'fixlight' ), 'wcag' => '2.4.1', 'level' => 'A', 'impact' => 'moderate', 'why' => __( 'Keyboard users have to press Tab through every menu item on every page before reaching the content.', 'fixlight' ), 'how' => __( 'Fixlight adds the link automatically when it can find the main content area. If it cannot, wrap your content in a <main> element.', 'fixlight' ), ), 'image-alt' => array( 'title' => __( 'Images without alt text', 'fixlight' ), 'wcag' => '1.1.1', 'level' => 'A', 'impact' => 'critical', 'why' => __( 'Screen readers read the file name instead ("IMG_2041 dot jpg"), or skip the image, so blind visitors miss what it shows.', 'fixlight' ), 'how' => __( 'Describe what the image shows in one short sentence. Use the Images screen to add alt text in bulk, or mark purely decorative images as decorative.', 'fixlight' ), ), 'frame-title' => array( 'title' => __( 'Embedded content without a title', 'fixlight' ), 'wcag' => '4.1.2', 'level' => 'A', 'impact' => 'serious', 'why' => __( 'Screen readers announce an untitled iframe as just "frame", so people do not know whether it is a video, a map or a form.', 'fixlight' ), 'how' => __( 'Fixlight fixes this automatically. You can also add a title attribute to the iframe code.', 'fixlight' ), ), 'meta-viewport' => array( 'title' => __( 'Zoom is blocked on phones', 'fixlight' ), 'wcag' => '1.4.4', 'level' => 'AA', 'impact' => 'critical', 'why' => __( 'People with low vision zoom in to read. Blocking zoom makes the site unreadable for them on a phone.', 'fixlight' ), 'how' => __( 'Fixlight fixes this automatically by removing user-scalable=no and maximum-scale from the viewport tag.', 'fixlight' ), ), 'label' => array( 'title' => __( 'Form fields without a label', 'fixlight' ), 'wcag' => '4.1.2', 'level' => 'A', 'impact' => 'critical', 'why' => __( 'A screen reader announces an unlabeled field as just "edit text", so the visitor cannot tell what to type.', 'fixlight' ), 'how' => __( 'Give every field a visible <label>. Fixlight uses the placeholder as a label when there is one; check your form plugin\'s settings for the rest.', 'fixlight' ), ), 'link-name' => array( 'title' => __( 'Links with no text', 'fixlight' ), 'wcag' => '2.4.4', 'level' => 'A', 'impact' => 'serious', 'why' => __( 'Icon-only links (social icons, logos without alt text) are announced as "link" with no destination, or as the raw web address.', 'fixlight' ), 'how' => __( 'Fixlight names social and common icon links automatically. For the rest, add alt text to the image inside the link or an aria-label to the link.', 'fixlight' ), ), 'button-name' => array( 'title' => __( 'Buttons with no text', 'fixlight' ), 'wcag' => '4.1.2', 'level' => 'A', 'impact' => 'critical', 'why' => __( 'An icon-only button (search, menu, close) is announced as just "button", so people cannot tell what it does.', 'fixlight' ), 'how' => __( 'Fixlight names common icon buttons automatically. For the rest, add an aria-label that says what the button does.', 'fixlight' ), ), 'link-ambiguous' => array( 'title' => __( '"Read more" and "click here" links', 'fixlight' ), 'wcag' => '2.4.4', 'level' => 'A', 'impact' => 'moderate', 'why' => __( 'Screen reader users often jump from link to link. Ten links called "Read more" give them no idea where each one goes.', 'fixlight' ), 'how' => __( 'Fixlight adds the post title to links that point to a post. For other links, write link text that names the destination.', 'fixlight' ), ), 'tabindex' => array( 'title' => __( 'Positive tabindex values', 'fixlight' ), 'wcag' => '2.4.3', 'level' => 'BP', 'impact' => 'serious', 'why' => __( 'A tabindex above 0 makes the Tab key jump to that element first, out of reading order, which confuses keyboard users.', 'fixlight' ), 'how' => __( 'Fixlight fixes this automatically. In your own code, use tabindex="0" or no tabindex at all.', 'fixlight' ), ), 'mouse-only-control' => array( 'title' => __( 'Controls that only work with a mouse', 'fixlight' ), 'wcag' => '2.1.1', 'level' => 'A', 'impact' => 'serious', 'why' => __( 'Some menus, search icons, toggles and close buttons react to a mouse click but cannot be reached or pressed with the keyboard, so keyboard and screen reader users cannot open them at all.', 'fixlight' ), 'how' => __( 'Fixlight fixes the ones it recognizes in Divi, Extra, Bloom and Monarch. For others, ask your developer to use a real <button> element for anything people click.', 'fixlight' ), ), 'link-new-tab' => array( 'title' => __( 'Links that open a new tab without warning', 'fixlight' ), 'wcag' => '3.2.5', 'level' => 'BP', 'impact' => 'minor', 'why' => __( 'A new tab breaks the Back button. People using screen readers or magnifiers often do not notice the change and get lost.', 'fixlight' ), 'how' => __( 'Fixlight adds a spoken warning automatically. Better: only open new tabs when it truly helps the visitor.', 'fixlight' ), ), 'link-pdf' => array( 'title' => __( 'Links to PDFs without warning', 'fixlight' ), 'wcag' => '2.4.4', 'level' => 'BP', 'impact' => 'minor', 'why' => __( 'PDFs open outside the page and are often hard to read with a screen reader. Visitors should know before they click.', 'fixlight' ), 'how' => __( 'Fixlight adds a spoken warning automatically. The PDF itself also needs to be accessible (tagged, with real text, not a scanned image).', 'fixlight' ), ), 'media-autoplay' => array( 'title' => __( 'Video or audio that plays on its own without controls', 'fixlight' ), 'wcag' => '2.2.2', 'level' => 'A', 'impact' => 'serious', 'why' => __( 'Moving or noisy content that cannot be paused distracts people with attention disorders and covers the voice of screen readers.', 'fixlight' ), 'how' => __( 'Fixlight adds controls automatically. Consider not autoplaying at all.', 'fixlight' ), ), 'video-caption' => array( 'title' => __( 'Videos without captions', 'fixlight' ), 'wcag' => '1.2.2', 'level' => 'A', 'impact' => 'critical', 'why' => __( 'Deaf and hard-of-hearing visitors cannot follow a video without captions.', 'fixlight' ), 'how' => __( 'Add a captions file (WebVTT) to the video. YouTube and Vimeo can generate captions for you to correct.', 'fixlight' ), ), 'empty-heading' => array( 'title' => __( 'Empty headings', 'fixlight' ), 'wcag' => '1.3.1', 'level' => 'BP', 'impact' => 'minor', 'why' => __( 'Screen reader users navigate by headings. An empty heading is announced as "heading level 2" with nothing after it.', 'fixlight' ), 'how' => __( 'Delete the empty heading block, or use spacing instead of an empty heading.', 'fixlight' ), ), 'heading-order' => array( 'title' => __( 'Heading levels skip', 'fixlight' ), 'wcag' => '1.3.1', 'level' => 'BP', 'impact' => 'moderate', 'why' => __( 'Jumping from a level 2 heading to a level 4 heading makes the page outline look like something is missing.', 'fixlight' ), 'how' => __( 'Choose heading levels by structure, not by size: one level 1 for the page title, level 2 for sections, level 3 inside them. Change the size with styling instead.', 'fixlight' ), ), 'page-has-heading-one' => array( 'title' => __( 'Page has no main heading', 'fixlight' ), 'wcag' => '1.3.1', 'level' => 'BP', 'impact' => 'moderate', 'why' => __( 'A level 1 heading tells screen reader users where the main content starts and what the page is about.', 'fixlight' ), 'how' => __( 'Make sure the page title is a level 1 heading. Most themes do this; page builders sometimes do not.', 'fixlight' ), ), 'duplicate-id-referenced' => array( 'title' => __( 'Duplicate ids used by labels or links', 'fixlight' ), 'wcag' => '4.1.2', 'level' => 'BP', 'impact' => 'moderate', 'why' => __( 'When two elements share an id, a label or skip link pointing at that id may connect to the wrong one.', 'fixlight' ), 'how' => __( 'Give each element a unique id. This usually comes from a widget or form placed twice on the same page.', 'fixlight' ), ), 'color-contrast' => array( 'title' => __( 'Text with too little contrast', 'fixlight' ), 'wcag' => '1.4.3', 'level' => 'AA', 'impact' => 'serious', 'why' => __( 'Light grey text on white, or white text on a light colour, is hard or impossible to read for people with low vision, older visitors, and anyone on a phone in sunlight.', 'fixlight' ), 'how' => __( 'Make the text darker or the background lighter until the contrast ratio is at least 4.5:1 (3:1 for large text). Change it in your theme\'s colour settings so it applies everywhere.', 'fixlight' ), ), 'link-in-text-block' => array( 'title' => __( 'Links shown only by colour', 'fixlight' ), 'wcag' => '1.4.1', 'level' => 'A', 'impact' => 'serious', 'why' => __( 'When a link inside a paragraph differs from the text only by colour, people who cannot tell those colours apart do not see that it is a link.', 'fixlight' ), 'how' => __( 'Turn on "Underline links in text" on the Fixes screen, or underline links in your theme\'s settings.', 'fixlight' ), ), 'region' => array( 'title' => __( 'Content outside the page regions', 'fixlight' ), 'wcag' => '', 'level' => 'BP', 'impact' => 'moderate', 'why' => __( 'Screen reader users jump between regions (header, navigation, main content, footer). Content outside any region is easy to miss.', 'fixlight' ), 'how' => __( 'This usually comes from the theme or a page builder section. Put page content inside the main area, or ask your theme developer to add header, main and footer regions.', 'fixlight' ), ), 'landmark-one-main' => array( 'title' => __( 'Page has no main content region', 'fixlight' ), 'wcag' => '', 'level' => 'BP', 'impact' => 'moderate', 'why' => __( 'A main region lets screen reader and keyboard users jump straight to the content, and it is what a skip link points to.', 'fixlight' ), 'how' => __( 'Use a page template that wraps the content in a <main> element. Blank page builder templates often leave it out.', 'fixlight' ), ), 'image-redundant-alt' => array( 'title' => __( 'Alt text repeats the text next to it', 'fixlight' ), 'wcag' => '', 'level' => 'BP', 'impact' => 'minor', 'why' => __( 'When the alt text says the same as the caption or link text next to it, screen reader users hear everything twice.', 'fixlight' ), 'how' => __( 'Describe what the image shows instead, or leave the alt text empty if the text next to it already says it all.', 'fixlight' ), ), 'target-size' => array( 'title' => __( 'Buttons and links too small to tap', 'fixlight' ), 'wcag' => '2.5.8', 'level' => 'AA', 'impact' => 'serious', 'why' => __( 'Small tap targets that sit close together are hard to hit for people with shaky hands, and for everyone on a phone.', 'fixlight' ), 'how' => __( 'Make icons and small links at least 24 by 24 pixels, or leave more space around them.', 'fixlight' ), ), 'table-headers' => array( 'title' => __( 'Data tables without header cells', 'fixlight' ), 'wcag' => '1.3.1', 'level' => 'A', 'impact' => 'serious', 'why' => __( 'Without header cells, a screen reader reads a table as a long list of numbers with no idea which column each belongs to.', 'fixlight' ), 'how' => __( 'In the table block, turn on "Header section". In code, use <th> for header cells.', 'fixlight' ), ), ); } /** * A rule's definition, falling back to sensible values for axe-only rules. * * @param string $rule_id Rule id. * @return array|null */ public static function rule( $rule_id ) { $rules = self::rules(); return isset( $rules[ $rule_id ] ) ? $rules[ $rule_id ] : null; } /** * Which fix resolves a rule, if any. * * @param string $rule_id Rule id. * @return string Fix id or empty string. */ public static function fix_for_rule( $rule_id ) { foreach ( self::fixes() as $fix_id => $fix ) { if ( in_array( $rule_id, $fix['rules'], true ) ) { return $fix_id; } } return ''; } /** * Converts axe tags (wcag2a, wcag21aa, best-practice, wcag143) into a level and criterion. * * @param string[] $tags axe rule tags. * @return array{level:string,wcag:string} */ public static function level_from_axe_tags( array $tags ) { $level = 'BP'; $wcag = ''; foreach ( $tags as $tag ) { if ( preg_match( '/^wcag2\d*(a{1,3})$/', $tag, $matches ) ) { $candidate = strtoupper( $matches[1] ); // Keep the strictest level that applies (A beats AA beats AAA for weighting). if ( 'BP' === $level || strlen( $candidate ) < strlen( $level ) ) { $level = $candidate; } } elseif ( preg_match( '/^wcag(\d)(\d)(\d+)$/', $tag, $matches ) ) { $wcag = $matches[1] . '.' . $matches[2] . '.' . $matches[3]; } } return array( 'level' => $level, 'wcag' => $wcag, ); } }