<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Selim Ataballyev - Блог</title><description>Заметки об инженерии, ИИ-процессах и доведении продукта до продакшена. Статьи целиком, не анонсы.</description><link>https://selim.services/</link><language>ru-ru</language><atom:link href="https://selim.services/ru/rss.xml" rel="self" type="application/rss+xml"/><item><title>Русская локаль для блога на Astro без CMS</title><link>https://selim.services/ru/blog/adding-russian-locale-astro-blog/</link><guid isPermaLink="true">https://selim.services/ru/blog/adding-russian-locale-astro-blog/</guid><description>Как я добавил двуязычные посты EN/RU в статический блог на Astro без headless CMS: дублирование роутов, общий slug, hreflang и переключатель языков.</description><pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Встроенный i18n в Astro - это примитив маршрутизации, а не готовый фреймворк. Это важное разграничение. Примитив маршрутизации означает: Astro знает о ваших локалях и генерирует нужные URL-префиксы, но больше ничего не даёт. Никакой функции &lt;code&gt;t()&lt;/code&gt;, никаких типизированных словарей перевода, никаких хелперов для переключателя языков, никаких SEO-тегов &lt;code&gt;&amp;lt;link rel=&quot;alternate&quot;&amp;gt;&lt;/code&gt;. Всё остальное нужно строить самому. Для маркетинговой страницы или компонентного UI этот пробел закрывается словарём переводов и несколькими утилитами. Для блога с MDX-постами ситуация другая - сами тела постов уже локализованы по своей природе. Здесь нужна не таблица ключей, а соглашение о парировании файлов: один файл на локаль, одинаковый slug, и функция, умеющая найти пару.&lt;/p&gt;
&lt;p&gt;Этот пост описывает, как это устроено на данном сайте. Сам пост и является доказательством: у него есть EN-файл и RU-файл с одинаковым slug, и переключатель языков в шапке позволяет переходить между ними. Если вы читаете по-русски и хотите проверить, нажмите переключатель - попадёте на английскую версию этой же страницы.&lt;/p&gt;
&lt;h2&gt;Дублирование роутов, а не переключение в рантайме&lt;/h2&gt;
&lt;p&gt;Первое решение касается формы URL-дерева. В конфиге Astro i18n есть флаг &lt;code&gt;prefixDefaultLocale&lt;/code&gt;. При его отключении английские страницы живут на &lt;code&gt;/&lt;/code&gt; и &lt;code&gt;/blog/&lt;/code&gt;, а русские - на &lt;code&gt;/ru/&lt;/code&gt; и &lt;code&gt;/ru/blog/&lt;/code&gt;. Страницы дублируются на диске: &lt;code&gt;src/pages/blog/index.astro&lt;/code&gt; и &lt;code&gt;src/pages/ru/blog/index.astro&lt;/code&gt; - это отдельные файлы. При сборке Astro генерирует отдельный статический HTML для каждого. Никакого определения локали в рантайме, никаких cookies, никаких редиректов. Запрос &lt;code&gt;/ru/blog/&lt;/code&gt; получает RU-индекс блога, &lt;code&gt;/blog/&lt;/code&gt; - EN. Локаль закодирована в URL, и этим всё сказано.&lt;/p&gt;
&lt;p&gt;Это и есть &quot;дублирование роутов&quot;. Звучит громоздко, но для личного блога с небольшим количеством постов - нет: дублируется оболочка страниц (индекс, страницы тегов, layout), а сами посты уже являются локально-специфичными файлами. Динамические роуты для постов (&lt;code&gt;src/pages/blog/[slug].astro&lt;/code&gt; и &lt;code&gt;src/pages/ru/blog/[slug].astro&lt;/code&gt;) каждый запрашивает коллекцию контента с фильтром по &lt;code&gt;lang&lt;/code&gt;, поэтому видят только посты своей локали. На уровне роутов ничего не шарится - только компоненты и утилиты.&lt;/p&gt;
&lt;p&gt;Главное преимущество такого подхода в том, что каждый URL - это статический HTML-файл. Никакого переключения локали на клиенте, никаких затрат на гидратацию, никакой цепочки редиректов. Русскоязычный читатель сохраняет в закладки &lt;code&gt;/ru/blog/adding-russian-locale-astro-blog/&lt;/code&gt; и получает этот файл с CDN-ноды без единой строчки JavaScript. Именно на это ставит Astro для статических сайтов, и для блога это верная ставка.&lt;/p&gt;
&lt;h2&gt;Один slug, два файла, два lang&lt;/h2&gt;
&lt;p&gt;В схеме коллекции контента есть два поля, на которых держится вся механика парирования: &lt;code&gt;lang&lt;/code&gt; (перечисление &lt;code&gt;en&lt;/code&gt; | &lt;code&gt;ru&lt;/code&gt;, по умолчанию &lt;code&gt;en&lt;/code&gt;) и &lt;code&gt;slug&lt;/code&gt; (необязательная строка). Оба объявлены в &lt;code&gt;src/content.config.ts&lt;/code&gt; вместе с остальным контрактом frontmatter:&lt;/p&gt;
&lt;figure&gt;
&lt;pre&gt;&lt;code&gt;// the two fields the pairing depends on, from the real schema in src/content.config.ts
lang: z.enum([&quot;en&quot;, &quot;ru&quot;]).default(&quot;en&quot;),
slug: z.string().optional(),
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;То, что &lt;code&gt;slug&lt;/code&gt; необязательный, сделано намеренно, и это первое, что стоит объяснить. Пост, который его не указал, получает slug из имени файла - через хелпер &lt;code&gt;postSlug&lt;/code&gt;, читающий &lt;code&gt;post.data.slug ?? post.id&lt;/code&gt;. Благодаря этому одноязычные посты (в том числе те, что синхронизируются с другого проекта и вообще не содержат поля &lt;code&gt;slug&lt;/code&gt;) не обязаны нести frontmatter, который им не нужен. Двуязычная пара включает механику сама, объявив одинаковый &lt;code&gt;slug&lt;/code&gt; в обоих файлах. Парирование - это то, что включают, а не налог, который платит каждый пост.&lt;/p&gt;
&lt;p&gt;Загрузчик glob из того же файла индексирует записи коллекции по имени файла. Это значит, что два файла в &lt;code&gt;src/content/blog/&lt;/code&gt; с разными именами - это два разных элемента, даже если в их frontmatter одинаковый &lt;code&gt;slug&lt;/code&gt;. А URL, по которому пост будет отрендерен, берётся из поля &lt;code&gt;slug&lt;/code&gt;, а не из имени файла. Поэтому соглашение для двуязычной пары выглядит так:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;src/content/blog/adding-russian-locale-astro-blog.mdx&lt;/code&gt; - EN-файл, &lt;code&gt;lang: en&lt;/code&gt;, &lt;code&gt;slug: adding-russian-locale-astro-blog&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/content/blog/adding-russian-locale-astro-blog-ru.mdx&lt;/code&gt; - RU-файл, &lt;code&gt;lang: ru&lt;/code&gt;, &lt;code&gt;slug: adding-russian-locale-astro-blog&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Разные имена файлов (чтобы загрузчик glob видел их как разные записи), одинаковые значения &lt;code&gt;slug&lt;/code&gt; (чтобы функция парирования нашла пару), противоположные значения &lt;code&gt;lang&lt;/code&gt; (чтобы нужный файл отдавался по роуту своей локали). Это всё соглашение. Zod-схема не проверяет уникальность slug внутри локали, поэтому нарушение соглашения приводит не к ошибке сборки, а к посту без пары. Для одного автора это приемлемо.&lt;/p&gt;
&lt;h2&gt;Как переключатель находит пару&lt;/h2&gt;
&lt;p&gt;Ключевая функция - &lt;code&gt;findPair&lt;/code&gt; в &lt;code&gt;src/lib/posts.ts&lt;/code&gt;:&lt;/p&gt;
&lt;figure&gt;
&lt;pre&gt;&lt;code&gt;export function findPair(
  post: PostEntry,
  allPosts: PostEntry[],
): PostEntry | undefined {
  const targetLang = post.data.lang === &quot;en&quot; ? &quot;ru&quot; : &quot;en&quot;;
  return allPosts.find(
    (p) =&amp;gt; p.data.slug === post.data.slug &amp;amp;&amp;amp; p.data.lang === targetLang,
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;Она перебирает все посты в поиске такого, у которого совпадает &lt;code&gt;slug&lt;/code&gt; текущего поста и противоположный &lt;code&gt;lang&lt;/code&gt;. Если оба инварианта выполнены - одинаковый slug и противоположный lang - возвращается пара. Если нет - &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;switcherHref&lt;/code&gt; использует &lt;code&gt;findPair&lt;/code&gt; для разрешения фактических URL:&lt;/p&gt;
&lt;figure&gt;
&lt;pre&gt;&lt;code&gt;export function switcherHref(
  post: PostEntry,
  allPosts: PostEntry[],
): { en: string; ru: string } {
  const pair = findPair(post, allPosts);
  const hasEn = post.data.lang === &quot;en&quot; || !!pair;
  const hasRu = post.data.lang === &quot;ru&quot; || !!pair;
  return {
    en: hasEn ? `/blog/${post.data.slug}/` : `/blog/`,
    ru: hasRu ? `/ru/blog/${post.data.slug}/` : `/ru/blog/`,
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;Переключатель получает два URL: один для EN-версии поста, другой для RU. Если пара найдена - URL указывает на пост. Если нет (например, я написал новый EN-пост и ещё не перевёл его) - переключатель ведёт на индекс блога нужной локали (&lt;code&gt;/blog/&lt;/code&gt; или &lt;code&gt;/ru/blog/&lt;/code&gt;), что всё равно рабочий адрес, а не битая ссылка. Это и есть механизм graceful degradation: однояязычные посты получают переключатель, который ведёт на индекс блога неподдерживаемой локали.&lt;/p&gt;
&lt;p&gt;Страницы роутов для постов (&lt;code&gt;src/pages/blog/[slug].astro&lt;/code&gt; и &lt;code&gt;src/pages/ru/blog/[slug].astro&lt;/code&gt;) вызывают &lt;code&gt;switcherHref&lt;/code&gt; при сборке для каждого поста и передают результирующий объект &lt;code&gt;{ en, ru }&lt;/code&gt; в компонент переключателя языков в layout. Разрешение URL происходит в момент сборки, и никакой логики в рантайме нет.&lt;/p&gt;
&lt;h2&gt;SEO-паритет: hreflang и canonical&lt;/h2&gt;
&lt;p&gt;Каждая страница локали выводит &lt;code&gt;&amp;lt;link rel=&quot;canonical&quot;&amp;gt;&lt;/code&gt;, указывающий на себя, и &lt;code&gt;&amp;lt;link rel=&quot;alternate&quot; hreflang=&quot;...&quot;&amp;gt;&lt;/code&gt;, указывающий на обе локали. Для двуязычной пары постов EN-страница по адресу &lt;code&gt;/blog/adding-russian-locale-astro-blog/&lt;/code&gt; сообщает о своём RU-аналоге по адресу &lt;code&gt;/ru/blog/adding-russian-locale-astro-blog/&lt;/code&gt;, и наоборот. Поисковые системы используют hreflang, чтобы понять, что это один и тот же контент на разных языках, а не дублирующиеся страницы. Без hreflang робот мог бы посчитать две страницы с похожей структурой дублями и понизить одну из них.&lt;/p&gt;
&lt;p&gt;Интеграция &lt;code&gt;@astrojs/sitemap&lt;/code&gt; автоматически собирает все генерируемые роуты, так что оба URL для локалей попадают в &lt;code&gt;sitemap.xml&lt;/code&gt; без дополнительной настройки. Canonical-теги находятся в &lt;code&gt;src/layouts/BaseLayout.astro&lt;/code&gt; и строятся на основе URL текущей страницы. Hreflang-альтернативы используют тот же объект &lt;code&gt;{ en, ru }&lt;/code&gt; из &lt;code&gt;switcherHref&lt;/code&gt;, когда страница является постом, или URL-ы индексов с префиксом локали для всех остальных страниц.&lt;/p&gt;
&lt;p&gt;Это та часть модели, которую в Astro приходится строить вручную - &lt;code&gt;@nuxtjs/i18n&lt;/code&gt; делает всё это автоматически через &lt;code&gt;useLocaleHead&lt;/code&gt;. В Astro SEO-метаданные - это явный HTML в layout, а значения вычисляются теми же утилитами &lt;code&gt;findPair&lt;/code&gt;/&lt;code&gt;switcherHref&lt;/code&gt;. Больше кода, больше контроля, тот же результат.&lt;/p&gt;
&lt;h2&gt;Чем эта модель не является&lt;/h2&gt;
&lt;p&gt;Несколько честных ограничений, которые стоит назвать прежде чем адаптировать этот паттерн.&lt;/p&gt;
&lt;p&gt;Никакого &lt;code&gt;t()&lt;/code&gt; для тел постов в рантайме. Каждый MDX-файл и есть перевод. Если добавить абзац в EN-пост, RU-пост не обновится сам - нужно обновить вручную. Для одного автора, который пишет напрямую на двух языках, это фича: перевод - это полноценный контент, а не подстановка ключей. Для команды с пайплайном переводов или для архива из сотни постов это не масштабируется.&lt;/p&gt;
&lt;p&gt;Уникальность slug внутри локали - это соглашение, а не ограничение. Zod-схема не отклонит два EN-поста с одинаковым slug. Если такое случится, динамический роут (&lt;code&gt;[slug].astro&lt;/code&gt;) отрендерит тот, который запрос вернёт первым, и молча проигнорирует второй. В личном блоге это поймаешь на ревью; в мультиавторной среде это потенциальная проблема, требующая явной проверки при сборке.&lt;/p&gt;
&lt;p&gt;Двуязычная модель неглубокая: сегодня одна пара, а все существующие EN-посты не имеют RU-версий. Переключатель для них ведёт на &lt;code&gt;/ru/blog/&lt;/code&gt; (graceful fallback), так что сайт не сломан, но и полного зеркала нет. Расширение покрытия означает добавление RU-файлов по одному - что и является намеренным выбором для сайта одного автора.&lt;/p&gt;
&lt;p&gt;Официальная документация Astro по i18n-маршрутизации на &lt;a href=&quot;https://docs.astro.build/en/guides/internationalization/&quot;&gt;docs.astro.build/en/guides/internationalization&lt;/a&gt; подробно описывает опции &lt;code&gt;prefixDefaultLocale&lt;/code&gt; и конфиг маршрутизации. Но механика парирования файлов и логика fallback-переключателя - это уже специфика конкретного приложения, и там её нет.&lt;/p&gt;
&lt;h2&gt;Итог&lt;/h2&gt;
&lt;p&gt;Тот же цикл работы с контентом, который делает добавление EN-поста тривиальным (бросил MDX-файл - готово), теперь работает и для двуязычных пар: бросаешь два MDX-файла с одинаковыми slug и противоположными lang, и переключатель, hreflang, sitemap и индексы обеих локалей включаются автоматически. Инфраструктурная цена - соглашение об именовании и один утилитный модуль на 75 строк, &lt;code&gt;src/lib/posts.ts&lt;/code&gt;, где живут &lt;code&gt;postSlug&lt;/code&gt;, &lt;code&gt;findPair&lt;/code&gt;, &lt;code&gt;collectTags&lt;/code&gt;, &lt;code&gt;postsByTag&lt;/code&gt; и &lt;code&gt;switcherHref&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Проверка живая: откройте этот пост, нажмите переключатель языков в шапке - и вы перейдёте на английскую версию. &lt;code&gt;findPair&lt;/code&gt; нашла пару по совпадению slug, &lt;code&gt;switcherHref&lt;/code&gt; разрешила URL &lt;code&gt;/blog/...&lt;/code&gt;, layout запёк его в статический HTML при сборке. Никакой логики в рантайме, никаких API-вызовов, никаких редиректов. Вот и вся модель.&lt;/p&gt;
</content:encoded><media:content url="https://selim.services/_astro/adding-russian-locale-astro-blog-ru.CSZ_-9fP.png" medium="image" width="1600" height="900"/><category>astro</category><category>i18n</category><category>localization</category><category>content-collections</category><category>frontend</category></item></channel></rss>