Siedem wersji językowych, dwie pipelines i dlaczego w ogóle tłumaczę

← Powrót do bloga

22 maja 20268 min read

Siedem wersji językowych, dwie pipelines i dlaczego w ogóle tłumaczę

Jak jedna osoba utrzymuje siedem wersji językowych bez ręcznego edytowania każdego pliku JSON i które rynki rekrutacyjne sprawiły, że warto było to zrobić.

i18nportfoliofrontend

Portfolio w tylko angielskim jest ważnym wyborem. Podobnie jak dostarczanie siedmiu wersji językowych, kiedy jesteś jedną osobą utrzymującą repozytorium: to nie jest „darmowe”, ale jest czytelne, jeśli traktujesz język jako zakres produktu, a nie polecenie, i automatyzujesz nudne części.

Ten post jest dłuższą wersją: które języki, dlaczego je wybrałem i co dokładnie robi kod (packages/types, packages/i18n, scripts/translate.mjs, apps/web/scripts/translate-blog.mjs).

Dlaczego te języki (strategia, a nie kaprys)

Nie wybrałem wersji językowych, ponieważ szablon został z nimi dostarczony. Nie zoptymalizowałem również „najwięcej mówiących na świecie” jako pojedynczej liczby. Zacząłem od geografii rekrutacji: spojrzałem na kraje i regiony, w których zatrudniani są programiści (oddaleni pracodawcy, silne krajowe rynki technologiczne, węzły UE) i zapytałem, które języki mogę realnie dostarczyć, aby czytelnicy mogli korzystać ze strony w swoim ojczystym języku, a nie tylko w języku angielskim. To jest wybór produktu dotyczący tego, kto może ocenić to portfolio, a nie dekoracja.

Angielski jako domyślny jest nadal niezastąpiony: jest to najszerszy wspólny mianownik dla technologii. Sześć nieangielskich wersji językowych w tym repozytorium to portugalski, hiszpański, niemiecki, polski, holenderski i estoński, wymienione w LOCALE_CONFIG w packages/types/src/index.ts. Razem z en, to siedem wersji językowych.

Zamysł jest prosty: jeśli ktoś, kto może zatrudnić lub współpracować ze mną, ląduje tutaj z Brazylii lub Portugalii, Hiszpanii lub Ameryki Łacińskiej, Niemiec, Austrii lub Szwajcarii, Polski, Holandii lub Estonii, chcę, aby czytał w języku, który odpowiada temu, jak faktycznie czyta, kiedy ma to znaczenie, jednocześnie akceptując, że jeden kod językowy nie może pokryć każdego rejestru kulturowego tych języków, i nie udaję, że może.

Czy każde zdanie brzmi jak tłumaczenie ludzkie spędziło godzinę nad nim? Nie. Maszynowe tłumaczenie i partie wspomagane przez LLM są częścią stosu. Obietnica jest węższa i, moim zdaniem, bardziej uczciwa: celowe pokrycie, recenzowane różnice w git i kopia, która nie jest cicho tylko angielska dla czytelników, którzy wolą inny język ojczysty.

Co oznacza „siedem wersji językowych” w tym kodzie

Wszystko, co musi wiedzieć „które języki istnieją”, powinno pochodzić z LOCALE_CONFIG: klucze en, pt, es, de, pl, nl, et, każdy z name, bcp47, i kod franc dla pomocy w wykrywaniu języka. Pakiet i18n ponownie eksportuje tę konfigurację i buduje locales jako Object.keys(LOCALE_CONFIG); patrz packages/i18n/src/index.ts.

Słowniki wykonawcze są statyczne JSON na wersję językową pod packages/i18n/src/messages/, importowane przez małą mapę importerów, aby pakowacze mogli rozwiązać każdy plik. Pomocnik htmlLangFromLocale odwzorowuje wersję językową na tag BCP 47 dla <html lang>. To jest historia „jednego źródła prawdy”: typy + pliki JSON + aplikacja Next wszystkie układają się na tym samym wyliczeniu wersji językowych.

Jeśli dodasz wersję językową, rozszerzysz LOCALE_CONFIG, dodasz nowy plik JSON i połączysz importer, a następnie uruchomisz potok tłumaczenia. Nie ma ukrytej listy wykonawczej, gdzie może dryfować.

// packages/types/src/index.ts
export const LOCALE_CONFIG = {
  en: { name: "English",    bcp47: "en", franc: "eng" },
  pt: { name: "Portugês",  bcp47: "pt", franc: "por" },
  es: { name: "Español",    bcp47: "es", franc: "spa" },
  de: { name: "Deutsch",    bcp47: "de", franc: "deu" },
  pl: { name: "Polski",     bcp47: "pl", franc: "pol" },
  nl: { name: "Nederlands", bcp47: "nl", franc: "nld" },
  et: { name: "Eesti",      bcp47: "et", franc: "est" },
} as const;

export type Locale = keyof typeof LOCALE_CONFIG;

Potok A: ciągi UI, en.json, plik blokady, hybrydowy MT/LLM

Autorytatywne kopie interfejsu użytkownika żyją w packages/i18n/src/messages/en.json. Inne języki są generowane; zasada projektu jest wyraźna: edytuj tylko angielski, a następnie uruchom pnpm translate z korzenia repozytorium, który wykonuje scripts/translate.mjs z załadowanym środowiskiem z apps/web/.env.local (patrz skrypt package.json w korzeniu).

Przyrostowe tłumaczenie i plik blokady

translate.mjs jest długi z ważnych powodów. Utrzymuje scripts/translate.lock.json: skróty hashowe sekcji źródła angielskiego na wersję językową, aby gdy zmienisz jedną część en.json, nie wyślesz na nowo całego słownika do API. To ma znaczenie, gdy en.json rośnie (o strony, projekty, doświadczenie) i dbasz o koszt i powtarzalność.

Skrypt również usuwa klucze, które zaginęły z angielskiego, więc pliki lokalne nie gromadzą gałęzi osieroconych, i może usunąć znane nietłumaczalne terminy (własne nazwy, marki technologiczne) przed żądaniem, a następnie połączyć je z powrotem; patrz zbiór DO_NOT_TRANSLATE i powiązane pomocniki w pliku.

Hybrydowe routing: LibreTranslate vs Groq

Wybór narzędzi był dyktowany przez koszt i dopasowanie. LibreTranslate jest samoobsługowy przez Docker bez kosztów za token, co sprawia, że jest to odpowiednie rozwiązanie dla krótkich, informacyjnych ciągów, takich jak nav, meta i błędy; te klucze nie wymagają poziomu szczegółowości LLM, a płacenie za znak za DeepL lub Google Translate byłoby zbyt kosztowne bez dodawania jakości. Groq obsługuje ścieżkę LLM, ponieważ jego wnioskowanie jest szybkie i tanie dla pracy wsadowej w porównaniu z OpenAI lub Anthropic przy tej samej objętości; domyślny model TRANSLATE_MODEL to mały model (llama-4-scout) wybrany specjalnie po to, aby utrzymać niski koszt wykonania, a nie ten sam model używany do czatu.

Skrypt nie wysyła wszystkiego do Groq. Obiekt CONFIG koduje rzeczywistą politykę:

  • Klucze narracyjne pasujące do wzorców w groqKeyPatterns (np. about.sections.*.body, home.hero.subheadline, projects.items.*.problem, experience.items.*.impact) przechodzą przez Groq dla każdej wersji językowej za pomocą shouldUseGroqForKey. Tytuły stanowisk (experience.items.*.role) są również zawsze Groq, ponieważ czyste MT często zwraca tytuły wyglądające jak angielskie bez zmian.
  • Krótsze lub bardziej informacyjne przestrzenie nazw są obciążone LibreTranslate za pomocą libreKeyPrefixes (np. meta, nav, errors, experience.labels), z LIBRETRANSLATE_URL ustawionym domyślnie na lokalny host przyjazny dla Dockera, chyba że zostanie zastąpiony.

Zatem: każda wersja językowa otrzymuje jakość LLM na polach narracyjnych; LibreTranslate obsługuje klucze informacyjne, gdzie tłumaczenie maszynowe jest wystarczająco dokładne. To jest kompromis między kosztem a jakością wbudowany w kod, a nie komentarz w README.

// scripts/translate.mjs
function shouldUseGroqForKey(localeCode, key) {
  return (
    matchesDotPattern(key, "experience.items.*.role") ||
    CONFIG.groqKeyPatterns.some((pat) => matchesDotPattern(key, pat))
  );
}

Środowisko i operacje, które faktycznie dotykasz

Dwa klucze Groq są celowo oddzielone. GROQ_API_KEY żyje w produkcji i obsługuje czat portfolio w czasie wykonywania; uruchomienie wsadowe tłumaczenia, które powoduje skok zużycia tokenów, zjadałoby tę samą limit szybkości i wpływa na rzeczywistych użytkowników. TRANSLATE_GROQ_API_KEY to inny klucz używany tylko w lokalnych uruchomieniach wsadowych, z własnym limitem. Awaryjny powrót do GROQ_API_KEY istnieje dla wygody, gdy nie jest skonfigurowany dedykowany klucz, a nie jako planowany setup.

TRANSLATE_MODEL wybiera model do tłumaczenia wsadowego (domyślny meta-llama/llama-4-scout-17b-16e-instruct w nagłówku skryptu), niezależny od GROQ_MODEL używanego przez czat. Domyślny jest celowo mały, szybki model, aby utrzymać niski koszt wykonania. Istnieją regulatory dla rozmiaru wsadu, 429 ponownych prób i rejestracji; przeczytaj blok komentarza na górze pliku w scripts/translate.mjs, zanim dostosujesz.

Użycie API dla Groq (i hostingu LibreTranslate, jeśli uruchomisz go lokalnie) to prawdziwe pieniądze. Zwycięstwo tutaj to kontrolę i automatyzacja, a nie magia. Plik blokady to to, co sprawia, że jest to zarządzalne: sekcje, które nie zmieniły się w języku angielskim, nie trafiają do żadnego API w następnym uruchomieniu.

Potok B: posty na blogu, angielski MDX, a następnie wsadowe tłumaczenie Groq

Posty na blogu nie są tłumaczone z tym samym hybrydowym routerem. pnpm translate:blog uruchamia apps/web/scripts/translate-blog.mjs, który czyta apps/web/content/blog/en/*.mdx i dla każdej wersji językowej docelowej w TARGET_LOCALES (pt, es, de, pl, nl, et), wywołuje Groq z rygorystycznym podpowiedzią: zachowuje strukturę YAML frontmatter, utrzymuje date i hero identyczne z źródłem angielskim, tłumaczy tytuł, opis, tagi i treść, nie psuje adresów URL. Pliki wyjściowe trafiają pod apps/web/content/blog/<locale>/.

Nie ma ścieżki LibreTranslate w tym skrypcie dzisiaj; tłumaczenie bloga to wsadowe tłumaczenie LLM na plik, z --locale, --file, --force, i --delay, aby zmniejszyć ból limitu szybkości. W czasie czytania Next ładuje po prostu MDX dla trasy locale; nie ma połączenia Groq, gdy odwiedzający otwiera stronę bloga.

Czas wykonywania: trasy, słowniki i ścieżki bloga

Aplikacja Next.js używa segmentu [locale] pod apps/web/app/[locale]/. Komponenty serwera wywołują normalizeLocale na parametrze, więc nieznane wartości spadają do en, a następnie getDictionary(locale) ładuje pasujący moduł JSON z packages/i18n. Dlatego każdy szablon strony wygląda tak samo strukturalnie: wszystkie czytają kopię ze słownika, a nie kodowane angielskie ciągi w JSX (z rzadkimi wyjątkami, które sygnalizuje lint).

Trasy bloga łączą tę wersję językową ze slugiem: opublikowane posty są odczytywane z content/blog/<locale>/<slug>.mdx, gdy slug jest włączony w publish.json. Zatem „obsługa siedmiu wersji językowych” to nie tylko skrypty tłumaczenia; to również statyczne trasy + statyczne pliki treści, które muszą istnieć dla każdej wersji językowej, o którą dbasz.

Obietnica jakości jest węższa niż literacki połysk: spójne potoki, recenzowane dane wyjściowe w git i uczciwa priorytetyzacja: pola narracyjne pt/es przez Groq, wszystko inne przez MT, chyba że klucz pasuje do wzorca roli. To nie jest tryb awaryjny; to jest określony zakres.

Jeśli budujesz to samo

Zacznij od tego, kogo chcesz czytać i które rynki rekrutacyjne są dla Ciebie ważne, a nie od „ilu flag”. Lista wersji językowych to decyzja produktowa; traktuj ją jak taką.

Zacznij małe. Jedna dodatkowa wersja językowa (najprawdopodobniej ta, w której masz profesjonalne powiązania lub aktywnie poszukujesz pracy) wystarczy, aby potwierdzić potok. Dodaj wpis do LOCALE_CONFIG, dodaj plik JSON, uruchom pnpm translate. Jeśli jakość wyjściowa jest akceptowalna dla tej wersji językowej, masz powtarzalną ścieżkę. Nie potrzebujesz sześciu nieangielskich wersji językowych, aby udowodnić, że architektura działa.

Podejmij decyzję o hybrydowej polityce wcześnie. LibreTranslate jest szybki i darmowy, jeśli uruchomisz go lokalnie; Groq dodaje jakość na polach narracyjnych, ale kosztuje tokeny i dodaje opóźnienie. Jeśli dostarczasz tylko jedną lub dwie nieangielskie wersje językowe, Groq-only dla całego słownika jest prostszy niż budowanie logiki routingu w shouldUseGroqForKey. Złożoność hybrydowa w translate.mjs opłaca się tylko wtedy, gdy masz wystarczająco kluczy i wystarczająco wersji językowych, że zmienność jakości tłumaczenia maszynowego faktycznie ma znaczenie.

Utrzymuj źródło angielskie kanoniczne i dane wyjściowe różniczkowe. Plik blokady w scripts/translate.lock.json nie jest fakultatywną ceremonią; to sprawia, że możesz uruchomić pnpm translate bez ponownego wydawania tokenów na sekcje, które nie zmieniły się. Każdy potok, który ponownie tłumaczy cały słownik przy każdym uruchomieniu, napotka problemy z kosztem i limitem szybkości, zanim Twoje portfolio będzie miało drugiego czytelnika. Zatwierdź wygenerowane pliki lokalne, abyś mógł sprawdzić każdą zmianę tłumaczenia w normalnym żądaniu pull.

Następnie wdroż jedno źródło angielskie, wygenerowane pliki satelitarne i skrypty, które możesz uruchomić w CI lub lokalnie, aby Twoje portfolio pozostało produktem, który możesz ewoluować, a nie kupą ręcznie edytowanych kopii.

Podsumowanie

Implementacja nie jest najtrudniejszą częścią. LOCALE_CONFIG, dwa skrypty wsadowe, plik blokady: każdy przyzwoity inżynier może to zbudować w weekend. Najtrudniejsza część to podjęcie decyzji na początku, że język jest ograniczeniem produktu, a nie funkcją, którą dodajesz, gdy reszta jest „gotowa”.

Jeśli traktujesz i18n jako dekorację, dostarczysz ją tylko raz i nigdy więcej nie dotkniesz. Jeśli traktujesz to jako zakres, z automatyzacją, recenzowanymi różnicami i uczciwą listą tego, czego nie obejmuje, i pozostaje utrzymywalna, i jesteś w kontroli nad tym, co Twoje portfolio przekazuje czytelnikom, których możesz nigdy nie spotkać w języku angielskim.

To jest właściwy zakład: nie że każde zdanie brzmi jak tłumaczenie ludzkie spędziło godzinę nad nim, ale że strona działa dla kogoś w Warszawie lub Tallinie, który woli nie czytać w języku angielskim, kiedy ma to znaczenie. Czy ten zakład się opłaci, to TBD.