build_css(); if ( '' !== $css ) { // There is no stylesheet to ship: every rule is generated from the // site owner's repair settings. Registering the handle with no // source gives wp_add_inline_style() something to attach to without // adding a request. wp_register_style( 'accessvane-fixes', false, array(), ACCESSVANE_VERSION ); wp_enqueue_style( 'accessvane-fixes' ); wp_add_inline_style( 'accessvane-fixes', $css ); } if ( ! $this->needs_script() ) { return; } wp_enqueue_script( 'accessvane-fixes', ACCESSVANE_URL . 'assets/js/fixes.js', array(), ACCESSVANE_VERSION, true ); wp_localize_script( 'accessvane-fixes', 'accessVaneFixes', array( 'skipLink' => AccessVane_Settings::enabled( 'fix_skip_link' ), 'scrollableFocus' => AccessVane_Settings::enabled( 'fix_scrollable_focus' ), 'landmarkLabels' => AccessVane_Settings::enabled( 'fix_landmark_labels' ), 'formLabels' => AccessVane_Settings::enabled( 'fix_form_labels' ), 'iframeTitles' => AccessVane_Settings::enabled( 'fix_iframe_titles' ), 'strings' => array( 'scrollableRegion' => __( 'Scrollable content', 'accessvane' ), 'embeddedContent' => __( 'Embedded content', 'accessvane' ), 'primaryNav' => __( 'Primary', 'accessvane' ), 'secondaryNav' => __( 'Secondary', 'accessvane' ), // A screen reader announces these to the visitor, so leaving // them in English gave half-translated names like // "Footer navigasjon" on a translated site. 'footerNav' => __( 'Footer', 'accessvane' ), 'headerNav' => __( 'Header', 'accessvane' ), 'sidebarNav' => __( 'Sidebar', 'accessvane' ), 'navSuffix' => __( 'navigation', 'accessvane' ), ), ) ); if ( AccessVane_Settings::enabled( 'fix_viewport_zoom' ) ) { // Attached to the footer script rather than printed into the head. // That is also the more correct moment: the theme's own viewport tag // exists in the DOM by then, so it can actually be rewritten instead // of being printed after us and winning. wp_add_inline_script( 'accessvane-fixes', $this->viewport_unlock_js() ); } } /** * Build the small stylesheet the CSS-only repairs need. * * Added inline rather than shipped as a file: it is well under a kilobyte, * and a separate request for that would be a worse trade than the extra * bytes in the document. * * @return string */ private function build_css() { if ( ! $this->needs_style() ) { return ''; } $css = ''; if ( AccessVane_Settings::enabled( 'fix_skip_link' ) ) { // Positioned off-screen rather than display:none — a hidden element // cannot receive keyboard focus, which would defeat the entire point // of a skip link. $css .= '.accessvane-skip-link{position:absolute;left:-9999px;top:0;z-index:100000;padding:12px 20px;background:#000;color:#fff;font:600 16px/1.2 system-ui,sans-serif;text-decoration:underline;border-radius:0 0 6px 0}'; $css .= '.accessvane-skip-link:focus,.accessvane-skip-link:focus-visible{left:0}'; } if ( AccessVane_Settings::enabled( 'fix_focus_visible' ) ) { $color = AccessVane_Settings::get( 'focus_ring_color', '#0b63ce' ); $width = (int) AccessVane_Settings::get( 'focus_ring_width', 3 ); // :focus-visible only, so the ring appears for keyboard users and // not on every mouse click — which is the reason themes strip focus // styles in the first place. $css .= sprintf( 'a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:%1$dpx solid %2$s !important;outline-offset:2px !important;box-shadow:0 0 0 %3$dpx #fff !important}', $width, $color, max( 1, $width - 1 ) ); } if ( AccessVane_Settings::enabled( 'fix_link_underline' ) ) { // Scoped to body copy only. Underlining every link on the site would // wreck menus, buttons and image links. $css .= '.entry-content a:not([class]),.post-content a:not([class]),.page-content a:not([class]),.wp-block-post-content a:not([class]){text-decoration:underline;text-underline-offset:2px}'; } // Settings are sanitised on save; this is belt-and-braces against a // stored value that somehow carries markup. return wp_strip_all_tags( $css ); } /** * Print the skip link as the first thing inside . * * @return void */ public function print_skip_link() { if ( ! AccessVane_Settings::enabled( 'fix_skip_link' ) ) { return; } $target = AccessVane_Settings::sanitize_fragment( AccessVane_Settings::get( 'skip_link_target', '#content' ) ); $text = trim( (string) AccessVane_Settings::get( 'skip_link_text', '' ) ); if ( '' === $text ) { $text = __( 'Skip to main content', 'accessvane' ); } printf( '%2$s', esc_attr( $target ), esc_html( $text ) ); } /** * Allow pinch-zoom regardless of what the theme asked for. * * The theme's own viewport tag is left in place and overridden from * JavaScript, because rewriting another plugin's wp_head output would mean * buffering the whole head — far more intrusive than this. * * @return string */ private function viewport_unlock_js() { return <<<'JS' (function(){ var tag=document.querySelector('meta[name="viewport"]'); if(!tag){tag=document.createElement('meta');tag.setAttribute('name','viewport');document.head.appendChild(tag);} var content=tag.getAttribute('content')||''; content=content.replace(/,?\s*user-scalable\s*=\s*(no|0)/gi,'').replace(/,?\s*maximum-scale\s*=\s*[0-9.]+/gi,''); if(!/width\s*=/i.test(content)){content='width=device-width'+(content?', '+content:'');} tag.setAttribute('content',content.replace(/^\s*,\s*/,'').trim()+', maximum-scale=5, user-scalable=yes'); })(); JS; } /** * Guarantee the html element carries a language. * * @param string $output Existing attributes. * @return string */ public function ensure_language_attribute( $output ) { if ( false !== stripos( (string) $output, 'lang=' ) ) { return $output; } $locale = get_bloginfo( 'language' ); if ( ! $locale ) { $locale = 'en'; } return trim( $output . ' lang="' . esc_attr( $locale ) . '"' ); } /** * Supply alt text for attachments that have none. * * Falls back to the image's own caption, then its title — never the file * name, which is noise rather than a description. If nothing usable exists * the alt is set to empty, which marks the image decorative; that is a * defensible default and is far better than a missing attribute, but the * Alt Text screen exists because a real description is better still. * * @param array $attr Image attributes. * @param WP_Post $attachment Attachment post. * @return array */ public function fill_attachment_alt( $attr, $attachment ) { if ( isset( $attr['alt'] ) && '' !== trim( $attr['alt'] ) ) { return $attr; } if ( ! $attachment instanceof WP_Post ) { $attr['alt'] = ''; return $attr; } $caption = trim( (string) $attachment->post_excerpt ); $title = trim( (string) $attachment->post_title ); if ( '' !== $caption ) { $attr['alt'] = $caption; return $attr; } // A title that is just the uploaded file name describes nothing. if ( '' !== $title && ! $this->looks_like_filename( $title ) ) { $attr['alt'] = $title; return $attr; } $attr['alt'] = ''; return $attr; } /** * Detect titles WordPress generated from a file name. * * @param string $title Attachment title. * @return bool */ private function looks_like_filename( $title ) { if ( preg_match( '/\.(jpe?g|png|gif|webp|avif|svg|bmp|tiff?)$/i', $title ) ) { return true; } // IMG_1234, DSC00042, screenshot-2026-08-24-at-10-04-11, 20260824123456. if ( preg_match( '/^(img|dsc|dscn|pxl|photo|image|screenshot|screen[ _-]?shot|untitled)[ _-]?\d*/i', $title ) ) { return true; } if ( preg_match( '/^\d{6,}$/', $title ) ) { return true; } return false; } /** * Give oEmbed iframes a title naming what they contain. * * @param string $html Embed markup. * @param string $url Embedded URL. * @return string */ public function title_oembed( $html, $url ) { if ( ! is_string( $html ) || false === stripos( $html, ']*\stitle\s*=\s*["\'][^"\']+["\']/i', $html ) ) { return $html; } $host = wp_parse_url( (string) $url, PHP_URL_HOST ); $host = $host ? preg_replace( '/^www\./i', '', $host ) : ''; $label = $host /* translators: %s: the website an embed comes from, for example youtube.com. */ ? sprintf( __( 'Embedded content from %s', 'accessvane' ), $host ) : __( 'Embedded content', 'accessvane' ); return preg_replace( '/