, puts hidden * label text inside links and buttons, and (through compatibility packs) puts * real buttons over icons that only worked with a mouse. It never rewrites * visible text and never touches the database. * * @package Fixlight */ namespace Fixlight; defined( 'ABSPATH' ) || exit; /** * Render-time fixer. */ class Fixer { const NEW_TAB_ID = 'fixlight-new-tab'; const PDF_ID = 'fixlight-pdf'; const MAIN_ID = 'fixlight-main'; /** * Link texts that say nothing about the destination (lowercase, no punctuation). * * @var string[] */ const AMBIGUOUS_TEXT = array( 'read more', 'more', 'continue reading', 'continue', 'learn more', 'click here', 'here', 'more info', 'more information', 'details', 'view more', 'see more', 'view', 'read the full article', 'full article', 'leer más', 'leer mas', 'más', 'mas', 'seguir leyendo', 'continuar leyendo', 'ver más', 'ver mas', 'saber más', 'saber mas', 'aquí', 'aqui', 'haz clic aquí', 'haz clic aqui', 'clic aquí', 'más información', 'mas informacion', ); /** * Applies the enabled fixes. * * @param string $html Full page HTML. * @param array $enabled Fix id => bool. * @param array $context { * Fixer context. * @type string $language Language tag such as en-US. * @type callable $document_title Returns the page title. * @type callable $attachment_alt Receives attachment ids, returns id => alt (false = decorative). * @type callable $post_title Receives a URL, returns the post title or ''. * @type callable $home_url Returns the site's home URL. * @type callable $site_name Returns the site name. * } * @return array{html:string,counts:array} */ public static function apply( $html, array $enabled, array $context ) { $result = array( 'html' => $html, 'counts' => array(), ); if ( ! is_string( $html ) || '' === $html ) { return $result; } if ( array_filter( $enabled ) ) { try { $doc = Html_Analyzer::analyze( $html ); $plan = self::plan( $doc, $enabled, $context ); if ( $plan['mods'] || $plan['inserts'] || $plan['body_insert'] || $plan['head_insert'] || null !== $plan['title_fill'] ) { $result['html'] = self::write( $html, $plan ); } $result['counts'] = $plan['counts']; } catch ( \Throwable $error ) { // A fix must never break a page: serve the original HTML. $result['html'] = $html; $result['counts'] = array(); } } $fixed = $result['html']; try { /** * Filters a page after Fixlight's own fixes, on live pages and in * "after" checks alike. Fixlight Pro applies approved fix rules here. * * @param string $html The page with Fixlight's fixes. * @param array $context Fixer context (language, title, alts...). */ $filtered = apply_filters( 'fixlight_fixed_html', $fixed, $context ); if ( is_string( $filtered ) && '' !== $filtered ) { $result['html'] = $filtered; } } catch ( \Throwable $error ) { $result['html'] = $fixed; } return $result; } /** * Decides every change without touching the HTML. * * @param array $doc Analysis result. * @param array $enabled Fix id => bool. * @param array $context See apply(). * @return array Plan: mods (ordinal => set/remove), inserts, counts. */ public static function plan( array $doc, array $enabled, array $context ) { $plan = array( 'mods' => array(), 'inserts' => array(), 'body_insert' => '', 'body_ord' => $doc['body_ord'], 'head_insert' => '', 'head_ord' => $doc['head_ord'], 'title_fill' => null, 'title_ord' => $doc['title_ord'], 'counts' => array(), ); $on = static function ( $fix ) use ( $enabled ) { return ! empty( $enabled[ $fix ] ); }; $set = static function ( $ord, $attribute, $value ) use ( &$plan ) { $plan['mods'][ $ord ]['set'][ $attribute ] = $value; }; $count = static function ( $fix ) use ( &$plan ) { $plan['counts'][ $fix ] = isset( $plan['counts'][ $fix ] ) ? $plan['counts'][ $fix ] + 1 : 1; }; $api = new Compat_Plan( $plan, $enabled, $context ); $need_new_tab_text = false; $need_pdf_text = false; // Page language. if ( $on( 'lang' ) && null !== $doc['html_ord'] ) { $html_element = isset( $doc['elements'][ $doc['html_ord'] ] ) ? $doc['elements'][ $doc['html_ord'] ] : null; $lang = $html_element && isset( $html_element['attrs']['lang'] ) ? $html_element['attrs']['lang'] : null; if ( ( ! is_string( $lang ) || '' === trim( $lang ) ) && ! empty( $context['language'] ) ) { $set( $doc['html_ord'], 'lang', $context['language'] ); $count( 'lang' ); } } // Page title. if ( $on( 'page_title' ) && ( null === $doc['title'] || '' === $doc['title'] ) && isset( $context['document_title'] ) ) { $title = trim( (string) call_user_func( $context['document_title'] ) ); if ( '' !== $title ) { if ( null === $doc['title_ord'] && null !== $doc['head_ord'] ) { $plan['head_insert'] = '' . esc_html( $title ) . ''; $count( 'page_title' ); } elseif ( null !== $doc['title_ord'] ) { $plan['title_fill'] = $title; $count( 'page_title' ); } } } // Skip link, pointing at the main content. An existing id is never changed: // themes use them in CSS and scripts. if ( $on( 'skip_link' ) && ! $doc['skip_link'] && null !== $doc['body_ord'] && null !== $doc['main'] ) { $target = $doc['main']['id']; if ( '' === $target ) { if ( ! isset( $doc['ids'][ self::MAIN_ID ] ) ) { $target = self::MAIN_ID; $set( $doc['main']['ord'], 'id', $target ); } } elseif ( isset( $doc['ids'][ $target ] ) && $doc['ids'][ $target ] > 1 ) { // The id is used twice, so a link to it may land on the other element. $target = ''; } if ( '' !== $target ) { $link = array( 'href' => '#' . $target, 'class' => 'fixlight-skip-link', ); if ( $on( 'theme_compat' ) ) { foreach ( Compat::packs() as $pack ) { if ( method_exists( $pack, 'skip_link' ) ) { $link = $pack::skip_link( $link, $context ); } } } $plan['body_insert'] .= '' . esc_html__( 'Skip to content', 'fixlight' ) . ''; $count( 'skip_link' ); if ( 'id' === $doc['main']['source'] ) { // Found by its id only: also make it the page's main landmark, which it had no other way to be. $set( $doc['main']['ord'], 'role', 'main' ); } } } // Collect attachment ids for images that have no alt attribute. $attachment_ids = array(); if ( $on( 'img_alt' ) ) { foreach ( $doc['elements'] as $element ) { if ( 'IMG' === $element['tag'] && self::image_needs_alt( $element ) ) { $id = self::attachment_id_from_class( $element ); if ( $id ) { $attachment_ids[ $id ] = true; } } } } // Images with alt="" that are the only content of a link: the link has no name at all. $linked_empty = array(); if ( $on( 'img_alt' ) ) { foreach ( $doc['elements'] as $ord => $element ) { if ( 'IMG' !== $element['tag'] || null === $element['link_ord'] || ! isset( $element['attrs']['alt'] ) || '' !== trim( (string) $element['attrs']['alt'] ) || $element['hidden'] ) { continue; } $link = isset( $doc['elements'][ $element['link_ord'] ] ) ? $doc['elements'][ $element['link_ord'] ] : null; $id = self::attachment_id_from_class( $element ); if ( $id && $link && ! Html_Analyzer::has_accessible_name( $link ) && '' === $link['hidden_text'] ) { $attachment_ids[ $id ] = true; $linked_empty[ $ord ] = $id; } } } $library_alts = array(); if ( $attachment_ids && isset( $context['attachment_alt'] ) ) { $library_alts = (array) call_user_func( $context['attachment_alt'], array_keys( $attachment_ids ) ); } $named_by_image = array(); foreach ( $linked_empty as $ord => $id ) { if ( isset( $library_alts[ $id ] ) && is_string( $library_alts[ $id ] ) && '' !== trim( $library_alts[ $id ] ) ) { $named_by_image[ $doc['elements'][ $ord ]['link_ord'] ] = true; } else { unset( $linked_empty[ $ord ] ); } } $title_lookups = 0; foreach ( $doc['elements'] as $ord => $element ) { $attrs = $element['attrs']; switch ( $element['tag'] ) { case 'IMG': if ( isset( $linked_empty[ $ord ] ) ) { $set( $ord, 'alt', trim( (string) $library_alts[ $linked_empty[ $ord ] ] ) ); $count( 'img_alt' ); break; } if ( ! $on( 'img_alt' ) || ! self::image_needs_alt( $element ) ) { break; } $id = self::attachment_id_from_class( $element ); if ( $id && isset( $library_alts[ $id ] ) && false === $library_alts[ $id ] ) { // Marked decorative in the media library. $set( $ord, 'alt', '' ); $count( 'img_alt' ); } elseif ( $id && isset( $library_alts[ $id ] ) && '' !== trim( (string) $library_alts[ $id ] ) ) { $set( $ord, 'alt', trim( (string) $library_alts[ $id ] ) ); $count( 'img_alt' ); } elseif ( self::is_tracking_pixel( $element ) ) { $set( $ord, 'alt', '' ); $count( 'img_alt' ); } break; case 'IFRAME': if ( ! $on( 'iframe_title' ) || $element['hidden'] ) { break; } $has_title = isset( $attrs['title'] ) && is_string( $attrs['title'] ) && '' !== trim( $attrs['title'] ); $has_label = isset( $attrs['aria-label'] ) && is_string( $attrs['aria-label'] ) && '' !== trim( $attrs['aria-label'] ); if ( ! $has_title && ! $has_label ) { $set( $ord, 'title', self::iframe_title( isset( $attrs['src'] ) ? (string) $attrs['src'] : '' ) ); $count( 'iframe_title' ); } break; case 'INPUT': case 'SELECT': case 'TEXTAREA': if ( ! $on( 'form_labels' ) || ! Html_Analyzer::control_needs_name( $element ) || Html_Analyzer::control_has_name( $element, $doc ) ) { break; } $placeholder = isset( $attrs['placeholder'] ) && is_string( $attrs['placeholder'] ) ? trim( $attrs['placeholder'] ) : ''; if ( '' !== $placeholder ) { $set( $ord, 'aria-label', $placeholder ); $count( 'form_labels' ); } break; case 'META': if ( ! $on( 'zoom' ) || ! isset( $attrs['name'] ) || 'viewport' !== strtolower( (string) $attrs['name'] ) || ! isset( $attrs['content'] ) ) { break; } $fixed = self::unlock_viewport( (string) $attrs['content'] ); if ( $fixed !== $attrs['content'] ) { $set( $ord, 'content', $fixed ); $count( 'zoom' ); } break; case 'VIDEO': case 'AUDIO': if ( ! $on( 'media_controls' ) || ! isset( $attrs['autoplay'] ) || isset( $attrs['controls'] ) ) { break; } // A muted background video is left alone: adding controls would change the design. if ( 'AUDIO' === $element['tag'] || ! isset( $attrs['muted'] ) ) { $set( $ord, 'controls', true ); if ( 'VIDEO' === $element['tag'] ) { $set( $ord, 'muted', true ); } $count( 'media_controls' ); } break; case 'BUTTON': $labelled = isset( $attrs['aria-label'] ) || isset( $attrs['aria-labelledby'] ); if ( $on( 'icon_labels' ) && ! $element['hidden'] && ( ! Html_Analyzer::has_accessible_name( $element ) || ( ! $labelled && Html_Analyzer::is_weak_name( $element['text'] ) ) ) ) { $label = '' !== $element['hidden_text'] ? $element['hidden_text'] : self::icon_label( '', $element['attrs'], $element['hint'], $context ); if ( '' !== $label ) { $api->label( $element, $label ); } } break; case 'A': if ( ! Html_Analyzer::is_live_link( $element ) ) { break; } $href = (string) $attrs['href']; $has_name = Html_Analyzer::has_accessible_name( $element ); $labelled = isset( $attrs['aria-label'] ) || isset( $attrs['aria-labelledby'] ); // A number alone (a share count) is a name that says nothing. $weak = ! $labelled && Html_Analyzer::is_weak_name( $element['text'] ); if ( $on( 'icon_labels' ) && ( ! $has_name || $weak ) && ! isset( $named_by_image[ $ord ] ) ) { $label = '' !== $element['hidden_text'] && ! $weak ? $element['hidden_text'] : self::icon_label( $href, $attrs, $element['hint'], $context ); if ( '' !== $label ) { $api->label( $element, $label ); $labelled = true; } } if ( $on( 'read_more' ) && ! $labelled && self::is_ambiguous( $element['text'] ) && $title_lookups < 40 && isset( $context['post_title'] ) ) { ++$title_lookups; $post_title = trim( (string) call_user_func( $context['post_title'], $href ) ); if ( '' !== $post_title ) { /* translators: 1: visible link text such as "Read more", 2: post title. */ $set( $ord, 'aria-label', sprintf( __( '%1$s: %2$s', 'fixlight' ), $element['text'], $post_title ) ); $count( 'read_more' ); } } $describedby = isset( $attrs['aria-describedby'] ) && is_string( $attrs['aria-describedby'] ) ? $attrs['aria-describedby'] : ''; $spoken = strtolower( $element['text'] . ' ' . ( isset( $attrs['aria-label'] ) && is_string( $attrs['aria-label'] ) ? $attrs['aria-label'] : '' ) ); if ( $on( 'new_tab' ) && isset( $attrs['target'] ) && '_blank' === strtolower( (string) $attrs['target'] ) && ! self::mentions_new_tab( $spoken ) ) { if ( ! self::has_token( $describedby, self::NEW_TAB_ID ) ) { $describedby = trim( $describedby . ' ' . self::NEW_TAB_ID ); $set( $ord, 'aria-describedby', $describedby ); $count( 'new_tab' ); } $rel = isset( $attrs['rel'] ) && is_string( $attrs['rel'] ) ? $attrs['rel'] : ''; if ( ! preg_match( '/(^|\s)noopener(\s|$)/i', $rel ) ) { $set( $ord, 'rel', trim( $rel . ' noopener' ) ); } $need_new_tab_text = true; } if ( $on( 'pdf_links' ) && self::is_pdf( $href ) && false === strpos( $spoken, 'pdf' ) ) { if ( ! self::has_token( $describedby, self::PDF_ID ) ) { $describedby = trim( $describedby . ' ' . self::PDF_ID ); $set( $ord, 'aria-describedby', $describedby ); $count( 'pdf_links' ); } $need_pdf_text = true; } break; } } if ( $on( 'theme_compat' ) ) { foreach ( Compat::packs() as $pack ) { $pack::plan( $doc, $api ); } } if ( $on( 'tabindex' ) ) { foreach ( $doc['positive_tabindex'] as $ord ) { $set( $ord, 'tabindex', '0' ); $count( 'tabindex' ); } } if ( null === $doc['body_ord'] ) { // No to attach the descriptions to: undo the changes that point at them. if ( $need_new_tab_text || $need_pdf_text ) { foreach ( $plan['mods'] as $ord => $mod ) { unset( $plan['mods'][ $ord ]['set']['aria-describedby'] ); } unset( $plan['counts']['new_tab'], $plan['counts']['pdf_links'] ); } } else { if ( $need_new_tab_text && ! isset( $doc['ids'][ self::NEW_TAB_ID ] ) ) { $plan['body_insert'] .= ''; } if ( $need_pdf_text && ! isset( $doc['ids'][ self::PDF_ID ] ) ) { $plan['body_insert'] .= ''; } } return $plan; } /** * Applies a plan to the HTML in one pass. * * @param string $html Original HTML. * @param array $plan Output of plan(). * @return string */ private static function write( $html, array $plan ) { $marker = 'data-fixlight-' . bin2hex( random_bytes( 4 ) ); $marks = array(); if ( '' !== $plan['body_insert'] && null !== $plan['body_ord'] ) { $marks[ $plan['body_ord'] ] = 'body'; } if ( '' !== $plan['head_insert'] && null !== $plan['head_ord'] ) { $marks[ $plan['head_ord'] ] = 'head'; } if ( null !== $plan['title_fill'] && null !== $plan['title_ord'] ) { $marks[ $plan['title_ord'] ] = 'title'; } foreach ( array_keys( $plan['inserts'] ) as $ord ) { if ( ! isset( $marks[ $ord ] ) ) { $marks[ $ord ] = 'i' . $ord; } } $last = max( array_merge( array( -1 ), array_keys( $plan['mods'] ), array_keys( $marks ) ) ); $processor = new \WP_HTML_Tag_Processor( $html ); $ordinal = -1; while ( $ordinal < $last && $processor->next_token() ) { // Count tags exactly as Html_Analyzer::analyze() does, or ordinals drift (
differs across WordPress versions). if ( '#tag' !== $processor->get_token_type() || $processor->is_tag_closer() ) { continue; } ++$ordinal; if ( isset( $plan['mods'][ $ordinal ] ) ) { $mod = $plan['mods'][ $ordinal ]; if ( ! empty( $mod['set'] ) ) { foreach ( $mod['set'] as $attribute => $value ) { $processor->set_attribute( $attribute, $value ); } } } if ( isset( $marks[ $ordinal ] ) ) { $processor->set_attribute( $marker, $marks[ $ordinal ] ); } } $out = $processor->get_updated_html(); // Every insert is found in this one string, then all are applied in a single pass, so a page // with many inserted buttons is not copied once per button. $edits = array(); // Each: offset, length to remove, text to put there, order added. $add = static function ( $at, $remove, $text ) use ( &$edits ) { $edits[] = array( $at, $remove, $text, count( $edits ) ); }; foreach ( $marks as $kind ) { $needle = $marker . '="' . $kind . '"'; $start = strpos( $out, $needle ); if ( false === $start ) { continue; } $end = self::tag_end( $out, $start ); if ( false === $end ) { continue; } // The marker attribute itself, with the space the processor put before it. $from = ( $start > 0 && ' ' === $out[ $start - 1 ] ) ? $start - 1 : $start; $add( $from, $start + strlen( $needle ) - $from, '' ); if ( 'title' === $kind ) { $close = stripos( $out, '\/])/i', $html, $match, PREG_OFFSET_CAPTURE, $from ) ) { $from = $match[0][1] + 1; if ( '/' === $match[1][0] ) { --$depth; if ( 0 === $depth ) { return $match[0][1]; } } else { ++$depth; } } return false; } /** * Position of the ">" that ends the tag containing $from, respecting quoted values. * * @param string $html HTML. * @param int $from Offset inside a tag, outside any attribute value. * @return int|false */ private static function tag_end( $html, $from ) { $length = strlen( $html ); $quote = ''; for ( $i = $from; $i < $length; $i++ ) { $char = $html[ $i ]; if ( '' !== $quote ) { if ( $char === $quote ) { $quote = ''; } } elseif ( '"' === $char || "'" === $char ) { $quote = $char; } elseif ( '>' === $char ) { return $i; } } return false; } /** * Whether an image is missing alt text and has no other accessible name. * * @param array $element Recorded IMG. * @return bool */ public static function image_needs_alt( array $element ) { $attrs = $element['attrs']; if ( isset( $attrs['alt'] ) || $element['hidden'] ) { return false; } if ( isset( $attrs['role'] ) && in_array( $attrs['role'], array( 'presentation', 'none' ), true ) ) { return false; } foreach ( array( 'aria-label', 'aria-labelledby' ) as $attribute ) { if ( isset( $attrs[ $attribute ] ) && is_string( $attrs[ $attribute ] ) && '' !== trim( $attrs[ $attribute ] ) ) { return false; } } return true; } /** * Attachment id from the wp-image-{id} class core adds to inserted images. * * @param array $element Recorded IMG. * @return int */ public static function attachment_id_from_class( array $element ) { if ( isset( $element['attrs']['class'] ) && is_string( $element['attrs']['class'] ) && preg_match( '/(?:^|\s)wp-image-(\d+)(?:\s|$)/', $element['attrs']['class'], $matches ) ) { return (int) $matches[1]; } return 0; } /** * Tracking pixels and spacers: both dimensions declared and 3px or less. * * @param array $element Recorded IMG. * @return bool */ private static function is_tracking_pixel( array $element ) { $width = isset( $element['attrs']['width'] ) ? $element['attrs']['width'] : null; $height = isset( $element['attrs']['height'] ) ? $element['attrs']['height'] : null; return is_string( $width ) && is_string( $height ) && is_numeric( $width ) && is_numeric( $height ) && (float) $width <= 3 && (float) $height <= 3; } /** * A title for an iframe based on where it comes from. * * @param string $src iframe src. * @return string */ public static function iframe_title( $src ) { $host = strtolower( (string) wp_parse_url( $src, PHP_URL_HOST ) ); $host = preg_replace( '/^www\./', '', $host ); $map = array( 'youtube.com' => __( 'YouTube video', 'fixlight' ), 'youtube-nocookie.com' => __( 'YouTube video', 'fixlight' ), 'youtu.be' => __( 'YouTube video', 'fixlight' ), 'player.vimeo.com' => __( 'Vimeo video', 'fixlight' ), 'vimeo.com' => __( 'Vimeo video', 'fixlight' ), 'google.com' => __( 'Google Map', 'fixlight' ), 'maps.google.com' => __( 'Google Map', 'fixlight' ), 'open.spotify.com' => __( 'Spotify player', 'fixlight' ), 'w.soundcloud.com' => __( 'SoundCloud player', 'fixlight' ), 'calendly.com' => __( 'Calendly booking calendar', 'fixlight' ), 'docs.google.com' => __( 'Google document', 'fixlight' ), 'facebook.com' => __( 'Facebook post', 'fixlight' ), 'platform.twitter.com' => __( 'X (Twitter) post', 'fixlight' ), 'instagram.com' => __( 'Instagram post', 'fixlight' ), 'tiktok.com' => __( 'TikTok video', 'fixlight' ), 'loom.com' => __( 'Loom video', 'fixlight' ), 'wistia.net' => __( 'Video', 'fixlight' ), 'fast.wistia.net' => __( 'Video', 'fixlight' ), 'recaptcha.net' => __( 'reCAPTCHA', 'fixlight' ), ); if ( 'google.com' === $host && false === strpos( $src, '/maps' ) ) { $host = 'docs.google.com'; } if ( isset( $map[ $host ] ) ) { return $map[ $host ]; } if ( false !== strpos( $host, 'recaptcha' ) || false !== strpos( $src, 'recaptcha' ) ) { return __( 'reCAPTCHA', 'fixlight' ); } if ( '' === $host ) { return __( 'Embedded content', 'fixlight' ); } /* translators: %s: website the embedded content comes from, such as example.com. */ return sprintf( __( 'Embedded content from %s', 'fixlight' ), $host ); } /** * Removes zoom blockers from a viewport meta content value. * * @param string $content Viewport content, e.g. "width=device-width, user-scalable=no". * @return string */ public static function unlock_viewport( $content ) { $parts = preg_split( '/\s*[,;]\s*/', trim( $content ) ); $keep = array(); foreach ( $parts as $part ) { if ( '' === $part ) { continue; } $pair = array_map( 'trim', explode( '=', $part, 2 ) ); $key = strtolower( $pair[0] ); $value = isset( $pair[1] ) ? strtolower( $pair[1] ) : ''; if ( 'user-scalable' === $key && in_array( $value, array( 'no', '0' ), true ) ) { continue; } if ( 'maximum-scale' === $key && is_numeric( $value ) && (float) $value < 2 ) { continue; } $keep[] = $part; } $fixed = implode( ', ', $keep ); return implode( ', ', array_filter( $parts, 'strlen' ) ) === $fixed ? $content : $fixed; } /** * Whether link text says nothing about the destination. * * @param string $text Link text. * @return bool */ public static function is_ambiguous( $text ) { $normalized = function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text ); $normalized = trim( Html_Analyzer::text_replace( '/[\s\x{00A0}]+/u', ' ', Html_Analyzer::text_replace( '/[^\p{L}\p{N}\s]+/u', ' ', $normalized ) ) ); return '' !== $normalized && in_array( $normalized, self::AMBIGUOUS_TEXT, true ); } /** * Whether text already warns about a new tab or window. The fixer and the * scanner both use this, so a link counts as fixed exactly when it is left alone. * * @param string $text Lowercase link text and label. * @return bool */ public static function mentions_new_tab( $text ) { return Html_Analyzer::text_matches( '/new (tab|window)|nueva (pestaña|ventana)|externa?l?\b/u', $text ); } /** * Whether a space-separated list such as aria-describedby contains a token. * * @param string $tokens List. * @param string $token Token. * @return bool */ private static function has_token( $tokens, $token ) { return in_array( $token, preg_split( '/\s+/', trim( $tokens ) ), true ); } /** * Whether a URL points at a PDF file. * * @param string $href Link URL. * @return bool */ public static function is_pdf( $href ) { $path = (string) wp_parse_url( $href, PHP_URL_PATH ); return '.pdf' === strtolower( substr( $path, -4 ) ); } /** * A label for an icon-only link or button, or '' when we cannot tell. * * @param string $href Link URL ('' for buttons). * @param array $attrs Element attributes. * @param string $hint Class names of the element's descendants. * @param array $context See apply(). * @return string */ public static function icon_label( $href, array $attrs, $hint, array $context ) { if ( '' !== $href ) { $scheme = strtolower( (string) wp_parse_url( $href, PHP_URL_SCHEME ) ); if ( 'tel' === $scheme ) { /* translators: %s: phone number. */ return sprintf( __( 'Call %s', 'fixlight' ), rawurldecode( substr( $href, 4 ) ) ); } if ( 'mailto' === $scheme ) { if ( '?' === substr( $href, 7, 1 ) || 7 === strlen( $href ) ) { return __( 'Share by email', 'fixlight' ); // No address: a "send this page to someone" link. } $address = strtok( rawurldecode( substr( $href, 7 ) ), '?' ); /* translators: %s: email address. */ return sprintf( __( 'Email %s', 'fixlight' ), $address ); } $host = preg_replace( '/^(www|m|mobile)\./', '', strtolower( (string) wp_parse_url( $href, PHP_URL_HOST ) ) ); $share = self::share_label( $host, (string) wp_parse_url( $href, PHP_URL_PATH ), (string) wp_parse_url( $href, PHP_URL_QUERY ) ); if ( '' !== $share ) { return $share; } foreach ( self::social_sites() as $domain => $name ) { if ( $host === $domain || ( strlen( $host ) > strlen( $domain ) && substr( $host, -strlen( $domain ) - 1 ) === '.' . $domain ) ) { return $name; } } } $classes = strtolower( ( isset( $attrs['class'] ) && is_string( $attrs['class'] ) ? $attrs['class'] : '' ) . ' ' . $hint ); if ( '' !== trim( $classes ) ) { $word = static function ( $pattern ) use ( $classes ) { return 1 === preg_match( '/(^|[\s_-])(' . $pattern . ')([\s_-]|$)/', $classes ); }; // A close button for a search box is "Close search", not "Search". if ( $word( 'close|cancel|times|xmark|dismiss' ) ) { return $word( 'search' ) ? __( 'Close search', 'fixlight' ) : __( 'Close', 'fixlight' ); } foreach ( self::social_sites() as $domain => $name ) { $brand = strtok( $domain, '.' ); // Short or common words (x, fb, medium) would match unrelated classes. if ( strlen( $brand ) < 4 || in_array( $brand, array( 'medium', 'threads' ), true ) ) { continue; } if ( preg_match( '/(^|[\s_-])' . preg_quote( $brand, '/' ) . '([\s_-]|$)/', $classes ) ) { return $name; } } foreach ( self::icon_words() as $pattern => $label ) { if ( preg_match( '/(^|[\s_-])(' . $pattern . ')([\s_-]|$)/', $classes ) ) { return $label; } } } // A logo link to the home page with no text: name it after the site. if ( '' !== $href && isset( $context['home_url'], $context['site_name'] ) ) { $home = untrailingslashit( (string) call_user_func( $context['home_url'] ) ); if ( '/' === $href || untrailingslashit( $href ) === $home ) { $site = trim( (string) call_user_func( $context['site_name'] ) ); if ( '' !== $site ) { /* translators: %s: site name. */ return sprintf( __( '%s home page', 'fixlight' ), $site ); } } } return ''; } /** * A label for a link that shares the page on a social network, or ''. * * @param string $host Host without www. * @param string $path URL path. * @param string $query URL query. * @return string */ private static function share_label( $host, $path, $query ) { $path = strtolower( $path ); $shares = array( 'facebook.com' => array( '/sharer', 'Facebook' ), 'twitter.com' => array( '/(intent|share)', 'X (Twitter)' ), 'x.com' => array( '/(intent|share)', 'X (Twitter)' ), 'linkedin.com' => array( '/share', 'LinkedIn' ), 'reddit.com' => array( '/submit', 'Reddit' ), 'api.whatsapp.com' => array( '/send', 'WhatsApp' ), 'wa.me' => array( '/', 'WhatsApp' ), 't.me' => array( '/share', 'Telegram' ), 'tumblr.com' => array( '/share', 'Tumblr' ), 'bsky.app' => array( '/intent/', 'Bluesky' ), ); if ( 'pinterest.com' === $host && 0 === strpos( $path, '/pin/create' ) ) { return __( 'Save to Pinterest', 'fixlight' ); } if ( isset( $shares[ $host ] ) && preg_match( '#^' . $shares[ $host ][0] . '#', $path ) && ( 'wa.me' !== $host || false !== strpos( $query, 'text=' ) ) ) { /* translators: %s: social network name. */ return sprintf( __( 'Share on %s', 'fixlight' ), $shares[ $host ][1] ); } return ''; } /** * Social and messaging sites by domain. * * @return array */ private static function social_sites() { return array( 'facebook.com' => 'Facebook', 'fb.com' => 'Facebook', 'instagram.com' => 'Instagram', 'x.com' => 'X (Twitter)', 'twitter.com' => 'X (Twitter)', 'linkedin.com' => 'LinkedIn', 'youtube.com' => 'YouTube', 'youtu.be' => 'YouTube', 'tiktok.com' => 'TikTok', 'pinterest.com' => 'Pinterest', 'wa.me' => 'WhatsApp', 'whatsapp.com' => 'WhatsApp', 't.me' => 'Telegram', 'telegram.me' => 'Telegram', 'github.com' => 'GitHub', 'threads.net' => 'Threads', 'bsky.app' => 'Bluesky', 'vimeo.com' => 'Vimeo', 'spotify.com' => 'Spotify', 'snapchat.com' => 'Snapchat', 'reddit.com' => 'Reddit', 'discord.gg' => 'Discord', 'discord.com' => 'Discord', 'tripadvisor.com' => 'Tripadvisor', 'yelp.com' => 'Yelp', 'etsy.com' => 'Etsy', 'behance.net' => 'Behance', 'dribbble.com' => 'Dribbble', 'medium.com' => 'Medium', 'twitch.tv' => 'Twitch', ); } /** * Icon class words (regex alternatives) and the label they mean. * * @return array */ private static function icon_words() { return array( 'search|magnify|magnifying-glass|lupa' => __( 'Search', 'fixlight' ), 'menu|hamburger|bars|navicon|menu-toggle|nav-toggle' => __( 'Menu', 'fixlight' ), 'cart|basket|shopping-cart|shopping-bag|minicart' => __( 'Cart', 'fixlight' ), 'account|user|profile|login|my-account' => __( 'Account', 'fixlight' ), 'wishlist|heart' => __( 'Wishlist', 'fixlight' ), 'prev|previous|arrow-left|chevron-left|angle-left' => __( 'Previous', 'fixlight' ), 'next|arrow-right|chevron-right|angle-right' => __( 'Next', 'fixlight' ), 'play' => __( 'Play', 'fixlight' ), 'pause' => __( 'Pause', 'fixlight' ), 'envelope|email|mail' => __( 'Email', 'fixlight' ), 'phone|call' => __( 'Phone', 'fixlight' ), 'rss|feed' => __( 'RSS feed', 'fixlight' ), 'print' => __( 'Print', 'fixlight' ), 'share' => __( 'Share', 'fixlight' ), 'scroll-top|back-to-top|to-top|scroll-to-top|arrow-up|chevron-up' => __( 'Back to top', 'fixlight' ), ); } }