` буквальным текстом в шаблоне, минуя эту функцию. * Такая ссылка ведёт на исходный язык, даже когда её вывели на `/en/...`, — * футер и другие жёстко закодированные в теме блоки страдают от этого чаще * всего. Этот фильтр — второй, независимый проход: правит то, что осталось * непрефиксованным после генерации страницы, каким бы способом ссылку ни * вывели. */ final class InternalLinks implements DocumentFilter { /** * CSS-класс, которым переключатель языков (NavMenu, LanguageSwitcher) * помечает свои собственные ссылки. * * Каждая такая ссылка уже ведёт на СВОЙ язык (ru → без префикса, * bg → `/bg/...`, en → `/en/...`), выбранный не по языку текущей * страницы, а по языку, который она предлагает. Если прогнать её через * общий проход ниже, префикс текущего языка страницы затрёт чужой — * переключатель перестанет вести куда должен. */ private const SWITCHER_CLASS = 'mlp-language-item'; /** * Пометка, которую ставит Translator на ссылку с переведённым адресом. * * Такой адрес владелец сайта задал вручную ИМЕННО для этого языка — * например, направил болгарскую кнопку на болгарский лендинг. Добавить * к нему языковой префикс значит отправить посетителя не туда, куда * его послали намеренно. Причина та же, что и у SWITCHER_CLASS выше: * ссылка уже ведёт куда надо, и общий проход ей только навредит. * * Атрибут служебный и до разметки не доходит — Translator снимает его * сразу после пост-обработчиков. */ public const TRANSLATED_ATTRIBUTE = 'data-lingocanvas-translated-href'; /** * @param UrlConverter $urls Построение языковых адресов. */ public function __construct( private readonly UrlConverter $urls ) { } /** * {@inheritDoc} */ public function apply( HtmlDocument $document, Language $target ): void { if ( $target->isDefault ) { return; } $basePath = LanguageResolver::basePath(); $homeHost = (string) ( wp_parse_url( (string) get_option( 'home' ), PHP_URL_HOST ) ?? '' ); $slugs = $this->urls->knownSlugs(); foreach ( $document->document()->getElementsByTagName( 'a' ) as $link ) { if ( ! $link->hasAttribute( 'href' ) ) { continue; } if ( self::hasClass( $link, self::SWITCHER_CLASS ) ) { continue; } if ( $link->hasAttribute( self::TRANSLATED_ATTRIBUTE ) ) { // Адрес задан вручную для этого языка — он уже ведёт куда надо. continue; } $href = trim( (string) $link->getAttribute( 'href' ) ); if ( ! self::isEligible( $href ) ) { continue; } /* * Проверку «внешний домен» раньше дублировали здесь ещё до * вызова withLanguagePrefix() — своей, регистрозависимой * версией. Домен в DNS регистронезависим, а `withLanguagePrefix()` * получает `$homeHost` и сама сравнивает его без учёта регистра * (см. UrlConverter::isOutsideInstallation()); вторая, более * грубая реализация того же правила только создавала третий * источник расхождений — как уже разошлись между собой * InternalLinks и SeoMeta до этой правки. */ $localized = UrlConverter::withLanguagePrefix( $href, $basePath, $target->slug, $slugs, $homeHost ); if ( $localized !== $href ) { $link->setAttribute( 'href', $localized ); } } } /** * Стоит ли вообще разбирать этот адрес. Чистая функция. * * Пропускаются `mailto:`, `tel:`, `javascript:`, `data:`, ссылки-якоря * (`#section`) и относительные адреса без ведущего слеша (`?filter=x`, * `search/`) — у них нет пути, который можно было бы безопасно * префиксовать, не разрушив исходный смысл ссылки. * * @param string $href Значение атрибута `href`. */ public static function isEligible( string $href ): bool { if ( '' === $href ) { return false; } return 1 === preg_match( '#^(https?://|//|/(?!/))#i', $href ); } /** * Несёт ли элемент указанный класс. Точное совпадение токена, а не * поиск подстроки: `mlp-language-item` не должен сработать на * `mlp-language-items-wrapper`. * * @param object $element Элемент DOM. * @param string $class Искомый класс. */ private static function hasClass( object $element, string $class ): bool { if ( ! $element->hasAttribute( 'class' ) ) { return false; } $classes = preg_split( '/\s+/', trim( (string) $element->getAttribute( 'class' ) ) ); return in_array( $class, $classes ?: array(), true ); } }