Русская локаль для блога на Astro без CMS
6 мин чтения
В посте про переписывание портфолио с Nuxt на Astro я упоминал, что встроенный i18n в Astro - это примитив маршрутизации, а не готовый фреймворк. Это важное разграничение. Примитив маршрутизации означает: Astro знает о ваших локалях и генерирует нужные URL-префиксы, но больше ничего не даёт. Никакой функции t(), никаких типизированных словарей перевода, никаких хелперов для переключателя языков, никаких SEO-тегов <link rel="alternate">. Всё остальное нужно строить самому. Для маркетинговой страницы или компонентного UI этот пробел закрывается словарём переводов и несколькими утилитами. Для блога с MDX-постами ситуация другая - сами тела постов уже локализованы по своей природе. Здесь нужна не таблица ключей, а соглашение о парировании файлов: один файл на локаль, одинаковый slug, и функция, умеющая найти пару.
Этот пост описывает, как это устроено на данном сайте. Сам пост и является доказательством: у него есть EN-файл и RU-файл с одинаковым slug, и переключатель языков в шапке позволяет переходить между ними. Если вы читаете по-русски и хотите проверить, нажмите переключатель - попадёте на английскую версию этой же страницы.
Дублирование роутов, а не переключение в рантайме
Первое решение касается формы URL-дерева. В конфиге Astro i18n есть флаг prefixDefaultLocale. При его отключении английские страницы живут на / и /blog/, а русские - на /ru/ и /ru/blog/. Страницы дублируются на диске: src/pages/blog/index.astro и src/pages/ru/blog/index.astro - это отдельные файлы. При сборке Astro генерирует отдельный статический HTML для каждого. Никакого определения локали в рантайме, никаких cookies, никаких редиректов. Запрос /ru/blog/ получает RU-индекс блога, /blog/ - EN. Локаль закодирована в URL, и этим всё сказано.
Это и есть "дублирование роутов". Звучит громоздко, но для личного блога с небольшим количеством постов - нет: дублируется оболочка страниц (индекс, страницы тегов, layout), а сами посты уже являются локально-специфичными файлами. Динамические роуты для постов (src/pages/blog/[slug].astro и src/pages/ru/blog/[slug].astro) каждый запрашивает коллекцию контента с фильтром по lang, поэтому видят только посты своей локали. На уровне роутов ничего не шарится - только компоненты и утилиты.
Главное преимущество такого подхода в том, что каждый URL - это статический HTML-файл. Никакого переключения локали на клиенте, никаких затрат на гидратацию, никакой цепочки редиректов. Русскоязычный читатель сохраняет в закладки /ru/blog/adding-russian-locale-astro-blog/ и получает этот файл с CDN-ноды без единой строчки JavaScript. Именно на это ставит Astro для статических сайтов, и для блога это верная ставка.
Один slug, два файла, два lang
В схеме коллекции контента есть два поля, на которых держится вся механика парирования: lang (перечисление en | ru, по умолчанию en) и slug (обычная строка, обязательная). Схема объявлена в src/content.config.ts:
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
lang: z.enum(["en", "ru"]).default("en"),
slug: z.string(),
})
Загрузчик glob из того же файла индексирует записи коллекции по имени файла. Это значит, что два файла в src/content/posts/ с разными именами - это два разных элемента, даже если в их frontmatter одинаковый slug. А URL, по которому пост будет отрендерен, берётся из поля slug, а не из имени файла. Поэтому соглашение для двуязычной пары выглядит так:
src/content/posts/adding-russian-locale-astro-blog.mdx- EN-файл,lang: en,slug: adding-russian-locale-astro-blogsrc/content/posts/adding-russian-locale-astro-blog-ru.mdx- RU-файл,lang: ru,slug: adding-russian-locale-astro-blog
Разные имена файлов (чтобы загрузчик glob видел их как разные записи), одинаковые значения slug (чтобы функция парирования нашла пару), противоположные значения lang (чтобы нужный файл отдавался по роуту своей локали). Это всё соглашение. Zod-схема не проверяет уникальность slug внутри локали, поэтому нарушение соглашения приводит не к ошибке сборки, а к посту без пары. Для одного автора это приемлемо.
Как переключатель находит пару
Ключевая функция - findPair в src/lib/posts.ts:
export function findPair(
post: PostEntry,
allPosts: PostEntry[],
): PostEntry | undefined {
const targetLang = post.data.lang === "en" ? "ru" : "en";
return allPosts.find(
(p) => p.data.slug === post.data.slug && p.data.lang === targetLang,
);
}
Она перебирает все посты в поиске такого, у которого совпадает slug текущего поста и противоположный lang. Если оба инварианта выполнены - одинаковый slug и противоположный lang - возвращается пара. Если нет - undefined.
switcherHref использует findPair для разрешения фактических URL:
export function switcherHref(
post: PostEntry,
allPosts: PostEntry[],
): { en: string; ru: string } {
const pair = findPair(post, allPosts);
const hasEn = post.data.lang === "en" || !!pair;
const hasRu = post.data.lang === "ru" || !!pair;
return {
en: hasEn ? `/blog/${post.data.slug}/` : `/blog/`,
ru: hasRu ? `/ru/blog/${post.data.slug}/` : `/ru/blog/`,
};
}
Переключатель получает два URL: один для EN-версии поста, другой для RU. Если пара найдена - URL указывает на пост. Если нет (например, я написал новый EN-пост и ещё не перевёл его) - переключатель ведёт на индекс блога нужной локали (/blog/ или /ru/blog/), что всё равно рабочий адрес, а не битая ссылка. Это и есть механизм graceful degradation: однояязычные посты получают переключатель, который ведёт на индекс блога неподдерживаемой локали.
Страницы роутов для постов (src/pages/blog/[slug].astro и src/pages/ru/blog/[slug].astro) вызывают switcherHref при сборке для каждого поста и передают результирующий объект { en, ru } в компонент переключателя языков в layout. Разрешение URL происходит в момент сборки, и никакой логики в рантайме нет.
SEO-паритет: hreflang и canonical
Каждая страница локали выводит <link rel="canonical">, указывающий на себя, и <link rel="alternate" hreflang="...">, указывающий на обе локали. Для двуязычной пары постов EN-страница по адресу /blog/adding-russian-locale-astro-blog/ сообщает о своём RU-аналоге по адресу /ru/blog/adding-russian-locale-astro-blog/, и наоборот. Поисковые системы используют hreflang, чтобы понять, что это один и тот же контент на разных языках, а не дублирующиеся страницы. Без hreflang робот мог бы посчитать две страницы с похожей структурой дублями и понизить одну из них.
Интеграция @astrojs/sitemap автоматически собирает все генерируемые роуты, так что оба URL для локалей попадают в sitemap.xml без дополнительной настройки. Canonical-теги находятся в src/layouts/BaseLayout.astro и строятся на основе URL текущей страницы. Hreflang-альтернативы используют тот же объект { en, ru } из switcherHref, когда страница является постом, или URL-ы индексов с префиксом локали для всех остальных страниц.
Это та часть модели, которую в Astro приходится строить вручную - @nuxtjs/i18n делает всё это автоматически через useLocaleHead. В Astro SEO-метаданные - это явный HTML в layout, а значения вычисляются теми же утилитами findPair/switcherHref. Больше кода, больше контроля, тот же результат.
Чем эта модель не является
Несколько честных ограничений, которые стоит назвать прежде чем адаптировать этот паттерн.
Никакого t() для тел постов в рантайме. Каждый MDX-файл и есть перевод. Если добавить абзац в EN-пост, RU-пост не обновится сам - нужно обновить вручную. Для одного автора, который пишет напрямую на двух языках, это фича: перевод - это полноценный контент, а не подстановка ключей. Для команды с пайплайном переводов или для архива из сотни постов это не масштабируется.
Уникальность slug внутри локали - это соглашение, а не ограничение. Zod-схема не отклонит два EN-поста с одинаковым slug. Если такое случится, динамический роут ([slug].astro) отрендерит тот, который запрос вернёт первым, и молча проигнорирует второй. В личном блоге это поймаешь на ревью; в мультиавторной среде это потенциальная проблема, требующая явной проверки при сборке.
Двуязычная модель неглубокая: сегодня одна пара, а все существующие EN-посты не имеют RU-версий. Переключатель для них ведёт на /ru/blog/ (graceful fallback), так что сайт не сломан, но и полного зеркала нет. Расширение покрытия означает добавление RU-файлов по одному - что и является намеренным выбором для сайта одного автора.
Официальная документация Astro по i18n-маршрутизации на docs.astro.build/en/guides/internationalization подробно описывает опции prefixDefaultLocale и конфиг маршрутизации. Но механика парирования файлов и логика fallback-переключателя - это уже специфика конкретного приложения, и там её нет.
Итог
Тот же цикл работы с контентом, который делает добавление EN-поста тривиальным (бросил MDX-файл - готово), теперь работает и для двуязычных пар: бросаешь два MDX-файла с одинаковыми slug и противоположными lang, и переключатель, hreflang, sitemap и индексы обеих локалей включаются автоматически. Инфраструктурная цена - соглашение об именовании и около пятидесяти строк утилит в src/lib/posts.ts.
Проверка живая: откройте этот пост, нажмите переключатель языков в шапке - и вы перейдёте на английскую версию. findPair нашла пару по совпадению slug, switcherHref разрешила URL /blog/..., layout запёк его в статический HTML при сборке. Никакой логики в рантайме, никаких API-вызовов, никаких редиректов. Вот и вся модель.