Перейти к содержимому
SEO7 мин чтения

hreflang, который не врёт: canonical на страницу и отсутствующий перевод

Два дефекта прошлой версии этого сайта: один canonical в корневом layout на тридцать страниц и hreflang, обещавший английскую версию русского текста. Оба лечатся типом, а не чеклистом.

Аудиторы двуязычных сайтов проверяют, что теги hreflang присутствуют и взаимны. Это полезно и это не то место, где такие сайты ломаются. Ломаются они там, где теги есть, они взаимны, и при этом сообщают неправду. Ниже два дефекта из предыдущей версии этого сайта — оба прошли бы любую автоматическую проверку.

Дефект первый: один canonical на весь сайт

В App Router метаданные наследуются. Объект metadata в app/layout.tsx действует на каждую страницу, которая его не переопределила. Если положить туда alternates.canonical, получится сайт, где около тридцати страниц объявляют главную своей канонической версией.

Это не «слабый сигнал, который поисковик учтёт с поправкой». Это инструкция: страница говорит, что она дубликат, и просит индексировать вместо себя другую. Хуже всего, что дефект не виден ни в одном чеклисте — тег на месте, синтаксис правильный, значение абсолютное. Ошибка в том, что значение одно и то же на всех страницах, а посмотреть на две страницы одновременно чеклист не умеет.

Поэтому canonical здесь считается для каждой страницы отдельно, и функция, которая его считает, принимает два аргумента:

export function alternatesFor(
  path: string,
  locale: Locale,
  { available = LOCALES }: { available?: readonly Locale[] } = {},
): { canonical: string; languages: Record<string, string> }

Второй аргумент — локаль страницы, которую сейчас рендерят, и он существует не для симметрии. Без него канонический адрес выводился бы только из локале-независимого пути, и каждая английская страница канонизировалась бы на свою русскую копию. Тот же самый дефект, просто масштабом меньше и заметить ещё труднее: английская версия исчезнет из индекса, а русская останется, и внешне всё будет выглядеть работающим.

Дефект второй: hreflang на перевод, которого нет

На старом сайте существовал /en/blog. Он отдавал русские тела статей под inLanguage: "en-US", потому что файл с английскими текстами был побайтовой копией русского. Тег hreflang="en" говорил: вот английская версия. Читатель нажимал переключатель языка и получал русский текст.

Это не опечатка и не недоделка, которую забыли закрыть. Это структурная проблема: если тип требует перевод в обоих языках, а перевода нет, самый дешёвый способ удовлетворить компилятор — скопировать. Тип, который делает нечестный вариант единственным выразимым, гарантированно получит нечестный вариант.

Отсюда асимметрия в lib/content/types.ts, единственная на весь файл:

title:   Localized<string>;                        // оба языка обязательны
excerpt: Localized<string>;                        // оба языка обязательны
body:    Partial<Record<"ru" | "en", ArticleHtml>>; // намеренно частичный

Заголовок и анонс — две строки, их можно перевести честно всегда, и английскому индексу они нужны везде, где он ссылается на статью. Полное тело — это работа на день. Сделав body обязательным в обоих языках, мы бы не получили переводы; мы бы получили давление в сторону машинного перевода ради зелёного билда. Partial здесь не послабление, а разрешение сказать правду: статья опубликована по-русски и не опубликована по-английски.

Одно место, которое знает, на каких языках существует документ

Дальше нужно, чтобы этот факт читали все, кому он нужен, и читали одинаково. В этом файле для этого ровно две функции:

export function entriesForLocale(locale: Locale): readonly JournalEntry[] {
  return JOURNAL.filter((entry) => hasBody(entry, locale));
}

export function localesForEntry(entry: JournalEntry): readonly Locale[] {
  return LOCALES.filter((locale) => hasBody(entry, locale));
}

Первая отвечает на вопрос «что показывать в этом языке» — её читают индекс журнала, sitemap.xml и RSS. Вторая отвечает на вопрос «какие языки можно перечислить в alternates» — её результат уходит в опцию available той самой alternatesFor(). Статья, которую вы читаете, — элемент массива, который эти две функции фильтруют.

Смысл не в экономии строк. Смысл в том, что четыре поверхности физически не могут разойтись: страница не попадёт в sitemap на языке, на котором её нет в индексе, и hreflang не сошлётся на адрес, которого нет в sitemap. Взаимность тегов получается структурно, а не потому, что кто-то её проверил.

hasBody проверяет не только наличие ключа, но и непустоту строки. body: { en: "" } проходит проверку типов и отправляет в sitemap пустую страницу — предикат в одну строку дешевле, чем разбираться, откуда в индексе взялся пустой документ.

x-default и почему он условный

x-default указывает на основной язык — здесь на русский. Но проставляется он только если русская версия действительно есть. Выглядит паранойей ровно до первой страницы, существующей только по-английски: тогда безусловный x-default отправляет весь остальной мир на 404.

Границы, на которых это ломается

Русская версия живёт в корне, английская — под /en, отдельного сегмента [locale] нет. Плюс очевиден: /pricing вместо /ru/pricing, и никакого редиректа на самом частом входе. Минус тоже надо назвать: маршрутное дерево перестаёт быть симметричным, и каждый хелпер обязан помнить, что у одного языка префикса нет. Именно поэтому все они лежат в одном модуле — второе место, которое решает «это английская страница?», и есть механизм расхождения.

Самая дешёвая ошибка в этой схеме выглядит так:

// startsWith("/en") классифицирует /enterprise как английскую страницу
pathname === "/en" || pathname.startsWith("/en/") ? "en" : "ru"

Проверка границы явная. Без неё любой путь, начинающийся на те же две буквы, получает английский <html lang>, английский canonical и cookie с выбранным языком.

Последнее: в proxy.ts нет редиректа по geo-IP. Причины две, и обе стоит знать. Первая — стоимость: Edge-инвокация на каждом холодном входе и некэшируемая главная страница. Вторая касается как раз честности hreflang. Googlebot ходит с американских адресов; редирект по стране покажет ему английскую страницу по русскому адресу — то есть сломает ровно то соответствие, которое hreflang должен описывать. Язык здесь выбирает человек переключателем, а proxy.ts всего лишь запоминает выбор.

Что из этого следует

Автоматическая проверка скажет, что hreflang есть. Она не скажет, что страница на другом конце написана не на том языке, и не скажет, что тридцать страниц объявили себя дубликатами одной. Оба этих факта видны только из кода, который порождает теги, — и поэтому имеет смысл, чтобы такого кода было ровно одно место, а не по одному на каждый шаблон страницы.