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.
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
.pokompilują 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?
| 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?
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ę:
- 01 Piszę po angielsku Angielski tekst trafia do komponentu, w makro.
- 02 Ekstrakcja bun run i18n:extract dopisuje nowe komunikaty do trzech katalogów i usuwa nieużywane.
- 03 Tłumaczenie Polski i hiszpański wypełniają nowe wpisy; hiszpański to tłumaczenie AI.
- 04 Testy Padają przy brakującym albo pustym tekście, zmienionym placeholderze, twardej spacji albo myślniku.
- 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
.astrotrzymam 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
- Internationalization (i18n) Routing (Astro Docs) (otwiera się w nowej karcie) (dostęp: )
- Routing: dynamic routes and getStaticPaths (Astro Docs) (otwiera się w nowej karcie) (dostęp: )
- Middleware (Astro Docs) (otwiera się w nowej karcie) (dostęp: )
- @astrojs/sitemap (Astro Docs) (otwiera się w nowej karcie) (dostęp: )
- Tell Google about localized versions of your page (Google Search Central) (otwiera się w nowej karcie) (dostęp: )
- Managing multi-regional and multilingual sites (Google Search Central) (otwiera się w nowej karcie) (dostęp: )
- Spam policies for Google web search (Google Search Central) (otwiera się w nowej karcie) (dostęp: )
- Static configuration with vercel.json: redirects (Vercel Docs) (otwiera się w nowej karcie) (dostęp: )
- Lingui: Vite plugin (otwiera się w nowej karcie) (dostęp: )
- Lingui: CLI reference (otwiera się w nowej karcie) (dostęp: )
- lingui-for: Lingui for Svelte and Astro (GitHub) (otwiera się w nowej karcie) (dostęp: )
- Understanding SC 3.1.2: Language of Parts (W3C WAI, WCAG 2.2) (otwiera się w nowej karcie) (dostęp: )
- Flag: Poland emoji (Emojipedia) (otwiera się w nowej karcie) (dostęp: )