i18n Astro

Wielojęzyczna strona w Astro z Lingui: adresy, hreflang i przełącznik języka, który nie gubi miejsca

Jak ta strona działa po angielsku, polsku i hiszpańsku z jednego statycznego builda Astro: adresy z językiem, slugi, hreflang i przełącznik języka.

Autor: Mateusz Muszarski Zaktualizowano 7 min czytania In English

Ta strona działa po angielsku, polsku i hiszpańsku z jednego statycznego builda Astro. Każda podstrona mieszka pod /en/, /pl/ albo /es/, teksty interfejsu siedzą w katalogach Lingui, a długie teksty, takie jak ten wpis, to osobny plik na każdy język. Wszystkie adresy buduje jeden mały moduł, więc link kanoniczny, tagi hreflang i przełącznik języka nie mogą się rozjechać.

TL;DR

  • Każdy adres zaczyna się od języka, a jeden moduł (routes.ts) buduje linki kanoniczne, hreflang i linki przełącznika.
  • Interfejsem zajmuje się Lingui: katalogi .po kompilują się przy imporcie, a build produkcyjny pada, gdy brakuje tekstu po polsku albo hiszpańsku.
  • Przełącznik zostawia Cię na tej samej stronie, jeśli istnieje tłumaczenie, a jeśli nie, prowadzi do sekcji w wybranym języku, nigdy do angielskiej treści pod adresem /pl/.

Jak wyglądają adresy?

Strona główna na trzech telefonach po angielsku, polsku i hiszpańsku: ten sam układ, a hiszpańskie linijki są najdłuższe
/en/, /pl/ i /es/: jeden układ, trzy katalogi. Układ musi pomieścić najdłuższy język, tutaj hiszpański.
Zasada Wartość
Języki en (źródłowy i domyślny), pl, es
Prefiks zawsze kod języka: /en/, /pl/, /es/
Bez prefiksu /, /cv/, /blog/<slug>/ przekierowują według języka przeglądarki
Ukośnik na końcu zawsze
<html lang> i hreflang same kody języka: en, pl, es
Slugi wspólne (work, cv, my-life); wpisy na blogu mogą mieć własne

Google zaleca osobne adresy URL dla każdej wersji językowej zamiast ciasteczek albo ustawień przeglądarki, które podmieniają język treści na tej samej stronie. Prefiks z kodem języka to najprostsza forma tej rady.

Co się dzieje pod adresem bez języka?

Na wydrukowanym CV jest muszarski.com/cv, a nie /en/cv/. Dlatego od 8 października 2026 każda podstrona odpowiada też bez kodu języka. vercel.json przekierowuje wtedy na brzegu sieci (307, czyli tymczasowo) według pierwszego języka z nagłówka Accept-Language: polski na /pl/, hiszpański na /es/, każdy inny na /en/. Slug wpisu prowadzi do języka, w którym wpis jest napisany.

Google pisze, że jego robot wysyła żądania bez nagłówka Accept-Language, więc boty dostają angielski. Google prosi też, żeby nie przekierowywać automatycznie z jednej wersji językowej na inną. Te przekierowania tego nie robią: wybierają wersję tylko dla adresu, który żadnej nie ma. Test jednostkowy sprawdza reguły na każdej podstronie i każdym wpisie, więc nowy polski wpis bez przekierowania wywraca testy.

Dlaczego folder [locale], a nie konfiguracja i18n z Astro?

Astro ma własne API do wielojęzycznego routingu: podajesz języki, wybierasz domyślny, decydujesz, czy dostaje prefiks, a linki budujesz pomocnikami w rodzaju getRelativeLocaleUrl(). Tryb manual wyłącza middleware i18n z Astro, żeby można było napisać własną logikę.

Nie skorzystałem z niego. Podstrony leżą w src/pages/[locale]/, czyli w dynamicznej trasie. W statycznym buildzie wszystkie trasy muszą być znane w czasie budowania, więc getStaticPaths() każdej podstrony zwraca jeden wpis na język, a blog jeden wpis na plik z artykułem. Skoro każdy język ma prefiks, reguły mieszczą się w jednym małym module. Jeśli nie chcesz go pisać, zacznij od wbudowanego API.

Jak jeden moduł buduje wszystkie adresy?

src/lib/routes.ts zna jeden kształt: ścieżkę bez języka. Podstrona z tym samym slugiem wszędzie przekazuje napis ("work"), a podstrona, której slug zależy od języka, mapę z jednym wpisem na język.

type LocalizedPath = string | Partial<Record<Locale, string>>;

export function pathTo(locale: Locale, path: LocalizedPath = ""): string {
  const local = typeof path === "string" ? path : path[locale];
  if (local === undefined) throw new Error(`No ${locale} version`);
  return `/${locale}/${clean(local)}`; // clean() dokłada ukośnik na końcu
}

export function switchPath(path: LocalizedPath, to: Locale, fallback: LocalizedPath = ""): string {
  if (localesOf(path).includes(to)) return pathTo(to, path);
  return localesOf(fallback).includes(to) ? pathTo(to, fallback) : pathTo(to);
}

pathTo rzuca błąd, gdy podstrona nie ma wersji w danym języku, więc link do nieistniejącego tłumaczenia wywraca build, zamiast trafić na produkcję. Przez tę funkcję przechodzą tag kanoniczny, tagi hreflang, nawigacja i przełącznik, a testy jednostkowe pilnują każdego przypadku.

Jak działają hreflang i x-default?

Zasady Google są krótkie. Każda wersja musi wskazywać samą siebie i wszystkie pozostałe. Jeśli dwie strony nie wskazują na siebie nawzajem, Google ignoruje te tagi. A adresy muszą być pełne, ze schematem https://. alternates() zamienia to na kod:

export function alternates(site: string, path: LocalizedPath): Alternate[] {
  const locales = localesOf(path);
  if (locales.length < 2) return []; // jeden język: nie ma czego łączyć w pary
  const origin = new URL(site).origin;
  const links = locales.map((l) => ({ hreflang: l, href: origin + pathTo(l, path) }));
  const fallback = locales.includes("en") ? "en" : locales[0];
  links.push({ hreflang: "x-default", href: origin + pathTo(fallback, path) });
  return links;
}

Tak wygląda nagłówek polskiej wersji mojego wpisu o Lingui po zbudowaniu:

<link rel="canonical" href="https://muszarski.com/pl/blog/lingui-czy-i18next/">
<link rel="alternate" hreflang="en" href="https://muszarski.com/en/blog/lingui-vs-i18next/">
<link rel="alternate" hreflang="pl" href="https://muszarski.com/pl/blog/lingui-czy-i18next/">
<link rel="alternate" hreflang="x-default" href="https://muszarski.com/en/blog/lingui-vs-i18next/">

Kody nie mają regionu, bo strony celują w języki, a nie w kraje; format Google to kod z ISO 639-1 z opcjonalnym regionem. x-default wskazuje angielski. Podstrona w jednym języku nie dostaje hreflang wcale: mój wpis o Claude Code tylko po polsku nie ma go z czym połączyć.

hreflang tylko łączy tłumaczenia w pary. Google rozpoznaje język strony po widocznej treści, a nie po atrybucie lang czy adresie URL.

Jak działają przetłumaczone slugi?

Podstrony mają wspólne slugi: /en/work/, /pl/work/, /es/work/. Wpisy na blogu mogą mieć własne: ten wpis to wielojezyczna-strona-astro-z-lingui po polsku i multilingual-astro-site-with-lingui po angielsku.

Każdy wpis ma w nagłówku translationKey. W czasie builda translationsOf(post) zbiera pliki z tym samym kluczem w mapę z języka na ścieżkę, a ta mapa to LocalizedPath, który layout przekazuje do alternates() i do przełącznika. Nowe tłumaczenie to jeden nowy plik z tym samym kluczem. Nic więcej się nie zmienia.

Dokąd prowadzi przełącznik języka?

Przełącznik w nagłówku
Jedno kliknięcie w brytyjską flagę: angielski odpowiednik tego wpisu, a nie angielska strona główna.

switchPath() próbuje trzech rzeczy po kolei: tej samej strony w wybranym języku, wersji sekcji zapasowej w tym języku, a na końcu strony głównej w tym języku. Z gotowych stron:

Jesteś na Wybierasz Lądujesz na
/pl/work/ English /en/work/
/pl/blog/lingui-czy-i18next/ English /en/blog/lingui-vs-i18next/
/pl/blog/claude-code-po-polsku/ (tylko po polsku) English /en/blog/
dowolny wpis na blogu Español /es/ (hiszpańskiego bloga nie ma)

Google radzi dodać linki do innych wersji, żeby czytelnik sam wybrał. Przełącznik jest dokładnie tym: zwykłymi linkami wygenerowanymi w czasie builda, bez JavaScriptu.

<a
  href={switchPath(path, to, fallback, locales)}
  hreflang={to}
  lang={to}
  aria-current={to === locale ? "true" : undefined}
>
  <span class={`flag flag-${to}`} aria-hidden="true" />
  <span class="sr-only">{AUTONYMS[to]}</span>
</a>

Flagi to gradient CSS i małe SVG wpisane w style, nie emoji: Emojipedia zaznacza, że emoji polskiej flagi na niektórych platformach może się wyświetlić jako litery PL. Prawdziwą etykietą jest nazwa języka w nim samym (English, Polski, Español): w nagłówku to tekst dla czytników ekranu i podpowiedź, a w menu na telefonie widać ją obok flagi. lang="pl" na słowie „Polski” pozwala czytnikowi wymówić je po polsku, i o to chodzi w kryterium WCAG 3.1.2 Language of Parts (poziom AA).

Jak Lingui działa w statycznym buildzie Astro?

Krótkie teksty (przyciski, nagłówki, komunikaty o błędach) to komunikaty Lingui. W plikach .astro działają dzięki lingui-for-astro, integracji społeczności (0.7.1, nie od zespołu Lingui), która przenosi makra Lingui do Astro. Komponent pisze t`Language` i dostaje tekst w języku strony.

Język ustawia middleware, które Astro uruchamia w czasie builda dla każdej prerenderowanej strony:

export const onRequest = defineMiddleware((context, next) => {
  const param = context.params.locale;
  const locale = isLocale(param) ? param : DEFAULT_LOCALE;
  context.locals.locale = locale;
  setLinguiContext(context.locals, getI18n(locale));
  return next();
});

getI18n() zwraca jedną zapamiętaną instancję Lingui na język. Nie ma globalnego activate(): build renderuje wszystkie trzy języki w jednym procesie, a globalny aktywny język byłby wspólny dla każdej renderowanej strony.

Skrypty w przeglądarce w ogóle nie ładują Lingui. Formularz kontaktowy dostaje na przykład przetłumaczone komunikaty o błędach jako JSON w atrybucie wygenerowanym w czasie builda, więc strona wysyła słowa, a nie katalog. Nowy tekst przechodzi przez taką pętlę:

  1. 01 Piszę po angielsku Angielski tekst trafia do komponentu, w makro.
  2. 02 Ekstrakcja bun run i18n:extract dopisuje nowe komunikaty do trzech katalogów i usuwa nieużywane.
  3. 03 Tłumaczenie Polski i hiszpański wypełniają nowe wpisy; hiszpański to tłumaczenie AI.
  4. 04 Testy Padają przy brakującym albo pustym tekście, zmienionym placeholderze, twardej spacji albo myślniku.
  5. 05 Build Wtyczka Vite kompiluje każdy plik .po przy imporcie i pada przy brakującym komunikacie.

Ostatnia bramka to failOnMissing: "catalog": build pada, gdy w katalogu polskim albo hiszpańskim brakuje komunikatu, i sprawdza to, zanim zadziałają języki zapasowe. Jak to wygląda na tle i18next, opisałem w Lingui czy i18next.

Jeden polski szczegół: jednoliterowe słowo, takie jak „w” czy „z”, nie powinno zostawać na końcu linii. Katalogi trzymają zwykłe spacje, a pomocnik typo(locale, text) wstawia twarde spacje dopiero przy renderowaniu.

Gdzie mieszkają długie teksty?

Nie w Lingui. Wpis na 1500 słów jako jeden komunikat w katalogu byłby trudny do przetłumaczenia, a jeszcze trudniejszy do sprawdzenia. Wpisy na blogu i dokumenty prawne to pliki kolekcji treści, osobne dla każdego języka, w folderach takich jak src/content/blog/pl/ i src/content/legal/es/.

Wpisy na blogu istnieją po angielsku i po polsku. Polityka prywatności i regulamin istnieją we wszystkich trzech językach, a test sprawdza, czy mają te same numerowane sekcje. Powstają po polsku i to polska wersja jest wiążąca.

Jak oznaczona jest wersja hiszpańska?

Hiszpański to tłumaczenie maszynowe, którego nie czytała jeszcze żadna osoba znająca ten język, i strona to mówi:

  • Nagłówek hiszpańskiego katalogu zawiera X-Review-Status: unreviewed (AI translation), a test pada, jeśli ten wpis zniknie.
  • Stopka każdej hiszpańskiej podstrony mówi po hiszpańsku, że tekst przetłumaczyła AI i nie sprawdził go jeszcze rodzimy użytkownik języka.
  • Dokumenty prawne powtarzają to obok zapisu o tym, która wersja językowa jest wiążąca.
  • Hiszpańskiego bloga nie ma: na hiszpańskich podstronach link do bloga prowadzi do bloga po angielsku i ma dopisek „(EN)”.

Narzędzie, które za tym stoi, opisuję w Tłumaczeniu katalogów Lingui przez LLM. Zasady Google dotyczące spamu wymieniają automatyczne przekształcenia, w tym tłumaczenie, użyte do masowego tworzenia stron, które niewiele dają użytkownikom. Garść podstron w portfolio to nie ta skala. Prawdziwym ryzykiem jest jakość: drewniane hiszpańskie zdanie przeczy staranności, którą pokazuje reszta strony.

Ograniczenia

  • Mapa strony łączy w pary tylko podstrony o tej samej ścieżce. /pl/work/ wskazuje swoje angielskie i hiszpańskie odpowiedniki, ale wpis z różnymi slugami w każdym języku nie wskazuje żadnych, więc jego hreflang jest tylko w nagłówku strony. Google to akceptuje, ale wolałbym zasilać mapę strony tą samą funkcją alternates().
  • Flagi oznaczają kraje, a nie języki. Angielski ma flagę Wielkiej Brytanii, bo odbiorcy są głównie w Europie; znaczenie niesie nazwa języka.
  • lingui-for-astro jest przed wersją 1.0 i rozwija je społeczność. W plikach .astro trzymam jak najmniej tekstu, więc rezygnacja z tej integracji kosztowałaby kilka jawnych wywołań i18n._().
  • Strona 404 jest na razie tylko po angielsku.
  • Hiszpański zostaje „niesprawdzony”, dopóki nie przeczyta go rodzimy użytkownik.

Jeśli chcesz zobaczyć przełącznik w działaniu, otwórz stronę z moimi projektami i zmień język: zostaniesz na tej samej stronie.

Źródła

  1. Internationalization (i18n) Routing (Astro Docs) (otwiera się w nowej karcie) (dostęp: )
  2. Routing: dynamic routes and getStaticPaths (Astro Docs) (otwiera się w nowej karcie) (dostęp: )
  3. Middleware (Astro Docs) (otwiera się w nowej karcie) (dostęp: )
  4. @astrojs/sitemap (Astro Docs) (otwiera się w nowej karcie) (dostęp: )
  5. Tell Google about localized versions of your page (Google Search Central) (otwiera się w nowej karcie) (dostęp: )
  6. Managing multi-regional and multilingual sites (Google Search Central) (otwiera się w nowej karcie) (dostęp: )
  7. Spam policies for Google web search (Google Search Central) (otwiera się w nowej karcie) (dostęp: )
  8. Static configuration with vercel.json: redirects (Vercel Docs) (otwiera się w nowej karcie) (dostęp: )
  9. Lingui: Vite plugin (otwiera się w nowej karcie) (dostęp: )
  10. Lingui: CLI reference (otwiera się w nowej karcie) (dostęp: )
  11. lingui-for: Lingui for Svelte and Astro (GitHub) (otwiera się w nowej karcie) (dostęp: )
  12. Understanding SC 3.1.2: Language of Parts (W3C WAI, WCAG 2.2) (otwiera się w nowej karcie) (dostęp: )
  13. Flag: Poland emoji (Emojipedia) (otwiera się w nowej karcie) (dostęp: )

Wszystkie wpisy

Artykuł 5 min czytania

Tłumaczenie katalogów Lingui przez LLM

Jak tłumaczyć katalogi .po z Lingui modelem językowym, co się psuje (zmienne, liczba mnoga, ton) i jakie testy puścić, zanim katalog trafi na produkcję.