Zeven locales, twee pijplijnen, en waarom ik überhaupt vertaalde

← Terug naar blog

22 mei 20269 min lezen

Zeven locales, twee pijplijnen, en waarom ik überhaupt vertaalde

Hoe één persoon zeven locales onderhoudt zonder elke JSON handmatig te bewerken, en welke arbeidsmarkten het waard waren om te bouwen.

i18nportfoliofrontend

Een portfolio in alleen Engels is een geldige keuze. Ook het leveren van zeven locales wanneer je één persoon bent die het repository onderhoudt, is niet “gratis”, maar het is begrijpelijk als je taal als productscope behandelt, niet als een checkbox, en je de saaie delen automatiseert.

Dit artikel is de lange versie: welke talen, waarom ik ze koos, en wat de code precies doet (packages/types, packages/i18n, scripts/translate.mjs, apps/web/scripts/translate-blog.mjs).

Waarom deze talen (strategie, geen stemming)

Ik koos geen locales omdat een sjabloon daarmee werd geleverd. Ik optimaliseerde niet voor “de meeste sprekers wereldwijd” als een enkel getal. Ik begon bij de geografie van de arbeidsmarkt: ik keek naar landen en regio's waar ontwikkelaars worden aangenomen (remote-vriendelijke werkgevers, sterke binnenlandse tech-markten, EU-hubs) en vroeg welke talen ik realistisch kon leveren zodat die lezers de site in hun moedertaal konden gebruiken, niet alleen in het Engels. Dat is een productkeuze over wie deze portfolio zou kunnen evalueren, geen decoratie.

Engels als standaard is nog steeds onmisbaar: het is de breedste gemeenschappelijke noemer voor tech. De zes niet-Engelse locales in dit repository zijn Portugees, Spaans, Duits, Pools, Nederlands en Ests, vermeld in LOCALE_CONFIG in packages/types/src/index.ts. Samen met en, dat zijn zeven oppervlakkige locales.

De bedoeling is duidelijk: als iemand die mij zou kunnen inhuren of samenwerken hier terechtkomt vanuit Brazilië of Portugal, Spanje of Latijns-Amerika, Duitsland, Oostenrijk of Zwitserland, Polen, Nederland of Estland, wil ik dat ze lezen in een taal die overeenkomt met hoe ze daadwerkelijk lezen als het ertoe doet, terwijl ze nog steeds accepteren dat één es veel landen dekt en één pt twee heel verschillende markten dekt. Dat is een afweging, geen onwetendheid. Eén locale code kan niet elke culturele register van die talen dekken, en ik doe alsof dat niet zo is.

Klinkt elke zin alsof een menselijke vertaler een uur aan het werk was? Nee. Machinevertaling en LLM-ondersteunde batches maken deel uit van de stapel. De belofte is smaller en, denk ik, eerlijker: doelbewuste dekking, controleerbare diffs in git, en kopie die niet stilzwijgend alleen Engels is voor lezers die een andere moedertaal prefereren.

Wat “zeven locales” betekent in deze codebase

Alles dat moet weten “welke talen bestaan” zou moeten afleiden uit LOCALE_CONFIG: sleutels en, pt, es, de, pl, nl, et, elk met name, bcp47, en een franc-code voor taaldetectiehelpers. Het i18n-pakket exporteert die configuratie opnieuw en bouwt locales als Object.keys(LOCALE_CONFIG); zie packages/i18n/src/index.ts.

Runtime-dictionaries zijn statische JSON per locale onder packages/i18n/src/messages/, geïmporteerd via een kleine importerkaart zodat bundelaars elk bestand kunnen oplossen. De htmlLangFromLocale-helper kaart een locale naar een BCP 47-tag voor <html lang>. Dat is het “enige bron van waarheid”-verhaal: types + JSON-bestanden + Next-app sluiten allemaal aan op dezelfde opsomming van locales.

Als je een locale toevoegt, breid je LOCALE_CONFIG uit, voeg je een nieuw JSON-bestand toe en bedraad je de importer, dan draai je de vertaalpijplijn. Er is geen verborgen runtimelijst elders die kan afwijken.

// packages/types/src/index.ts
export const LOCALE_CONFIG = {
  en: { name: "English",    bcp47: "en", franc: "eng" },
  pt: { name: "Portugees",  bcp47: "pt", franc: "por" },
  es: { name: "Spaans",    bcp47: "es", franc: "spa" },
  de: { name: "Duits",    bcp47: "de", franc: "deu" },
  pl: { name: "Pools",     bcp47: "pl", franc: "pol" },
  nl: { name: "Nederlands", bcp47: "nl", franc: "nld" },
  et: { name: "Ests",      bcp47: "et", franc: "est" },
} as const;

export type Locale = keyof typeof LOCALE_CONFIG;

Pijplijn A: UI-tekenreeksen, en.json, lockfile, hybride MT/LLM

Autoritaire UI-kopie leeft in packages/i18n/src/messages/en.json. Andere talen worden gegenereerd; de projectregel is expliciet: bewerk alleen Engels, draai dan pnpm translate vanuit de repositoryroot, die scripts/translate.mjs uitvoert met omgeving geladen vanuit apps/web/.env.local (zie het root package.json-script).

Incrementele vertaling en de lockfile

translate.mjs is lang om goede redenen. Het onderhoudt scripts/translate.lock.json: per-locale hashes van secties van de Engelse bron zodat als je één deel van en.json verandert, je niet blindelings de hele dictionary naar een API stuurt. Dat is belangrijk als en.json groeit (over pagina's, projecten, ervaring) en je geeft om kosten en herhaalbaarheid.

Het script verwijdert ook sleutels die uit het Engels zijn verdwenen, zodat locale bestanden geen verlaten takken accumuleren, en het kan bekende onvertalbare termen (eigen namen, tech-merken) voor een verzoek strippen, dan ze weer samenvoegen; zie de DO_NOT_TRANSLATE-set en gerelateerde helpers in het bestand.

Hybride routing: LibreTranslate vs Groq

De keuze van tools werd gedreven door kosten en pasvorm. LibreTranslate is zelf-hostbaar via Docker zonder kosten per token, waardoor het de juiste pasvorm is voor korte, informatieve tekenreeksen zoals nav, meta en errors; die sleutels hebben geen LLM-niveau van nuance nodig, en betalen per karakter aan DeepL of Google Translate zou snel oplopen zonder kwaliteit toe te voegen. Groq behandelt het LLM-traject omdat zijn inferentie snel en goedkoop is voor batchwerk in vergelijking met OpenAI of Anthropic bij hetzelfde volume; het standaard TRANSLATE_MODEL is een klein model (llama-4-scout) geselecteerd om de kosten per run laag te houden, niet hetzelfde model dat voor de chat wordt gebruikt.

Het script stuurt niet alles naar Groq. Een CONFIG-object codeert het echte beleid:

  • Verhaal-sleutels die overeenkomen met patronen in groqKeyPatterns (bijv. about.sections.*.body, home.hero.subheadline, projects.items.*.problem, experience.items.*.impact) gaan door Groq voor elke locale via shouldUseGroqForKey. Functietitels (experience.items.*.role) zijn ook altijd Groq, omdat pure MT vaak Engelse titels ongewijzigd retourneert.
  • Kortere of meer informatieve naamruimten worden gekanteld naar LibreTranslate via libreKeyPrefixes (bijv. meta, nav, errors, experience.labels), met LIBRETRANSLATE_URL standaard ingesteld op een lokale Docker-vriendelijke host tenzij je het overschrijft.

Dus: elke locale krijgt LLM-kwaliteit op verhaalvelden; LibreTranslate behandelt de informatieve sleutels waar machinevertaling nauwkeurig genoeg is. Dat is een afweging van kosten en kwaliteit ingebakken in code, geen commentaar in een README.

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

Omgeving en bewerkingen die je daadwerkelijk aanraakt

De twee Groq-sleutels zijn opzettelijk gescheiden. GROQ_API_KEY leeft in productie en dient de portfolio-chat op runtime; vertaalbatchruns die tokengebruik pieken zouden de dezelfde rate limit opbrengen en echte gebruikers beïnvloeden. TRANSLATE_GROQ_API_KEY is een andere sleutel die alleen wordt gebruikt in lokale batchruns, met zijn eigen quotum. De fallback naar GROQ_API_KEY bestaat voor het gemak als er geen speciale sleutel is geconfigureerd, niet als de beoogde installatie.

TRANSLATE_MODEL kiest het model voor batchvertaling (standaard meta-llama/llama-4-scout-17b-16e-instruct in het scriptheader), onafhankelijk van GROQ_MODEL gebruikt door de chat. De standaard is opzettelijk een klein, snel model om de kosten per run laag te houden. Er zijn knoppen voor batchgrootte, 429-herhalingen en logging; lees het commentaarblok bovenaan het bestand in scripts/translate.mjs voordat je het aanpast.

API-gebruik voor Groq (en hosting LibreTranslate als je het lokaal uitvoert) is echt geld. De winst hier is controle en automatisering, geen magie. De lockfile is wat het beheersbaar maakt: secties die niet in het Engels zijn veranderd, gaan niet naar enige API bij de volgende run.

Pijplijn B: Blogberichten, Engels MDX, dan Groq-only batch

Blogberichten worden niet vertaald met dezelfde hybride router. pnpm translate:blog draait apps/web/scripts/translate-blog.mjs, die apps/web/content/blog/en/*.mdx leest en voor elke doel-locale in TARGET_LOCALES (pt, es, de, pl, nl, et), Groq aanroept met een strikte prompt: behoud YAML-frontmatterstructuur, houd date en hero identiek aan de Engelse bron, vertaal titel, beschrijving, tags en lichaam, misbruik geen URLs. Uitvoerbestanden landen onder apps/web/content/blog/<locale>/.

Er is geen LibreTranslate-pad in dat script vandaag; blogvertaling is LLM-batch per bestand, met --locale, --file, --force, en --delay om pijn van rate-limiting te verminderen. Op leestijd laadt Next gewoon de MDX voor de route's locale; geen Groq-oproep gebeurt als een bezoeker een blogpagina opent.

Runtime: routes, dictionaries en blogpaden

De Next.js-app gebruikt het [locale]-segment onder apps/web/app/[locale]/. Servercomponenten roepen normalizeLocale aan op de parameter zodat onbekende waarden terugvallen op en, dan getDictionary(locale) laadt het overeenkomende JSON-module uit packages/i18n. Daarom ziet elke paginasjabloon er structureel hetzelfde uit: ze lezen allemaal kopie uit de dictionary, geen harcoded Engelse tekenreeksen in JSX (met zeldzame uitzonderingen die de linter vlagt).

Blogroutes combineren die locale met de slug: gepubliceerde berichten worden gelezen uit content/blog/<locale>/<slug>.mdx als de slug is ingeschakeld in publish.json. Dus “ondersteuning van zeven locales” is niet alleen vertaal-scripts; het is ook statische routes + statische inhoudsbestanden die moeten bestaan voor elke taal die je belangrijk vindt.

De belofte op kwaliteit is smaller dan literaire verfijning: consistente pijplijnen, controleerbare uitvoer in git, en eerlijke priorisatie: pt/es verhaalvelden via Groq, alles anders via MT tenzij de sleutel overeenkomt met het rolpatroon. Dat is geen faalmodus; het is een gesteld doel.

Als je hetzelfde bouwt

Begin bij wie je wilt lezen en welke arbeidsmarkten belangrijk voor je zijn, niet van “hoeveel vlaggen.” De lijst van locales is een productbeslissing; behandel het als zodanig.

Begin klein. Eén extra locale (waarschijnlijk degene waar je professionele connecties hebt of actief op zoek bent naar een baan) is genoeg om de pijplijn te valideren. Voeg een LOCALE_CONFIG-invoer toe, voeg het JSON-bestand toe, draai pnpm translate. Als de uitvoerkwaliteit acceptabel is voor die locale, heb je een herhaalbaar pad. Je hebt geen zes niet-Engelse locales nodig om te bewijzen dat de architectuur werkt.

Beslis vroeg over je hybride beleid. LibreTranslate is snel en gratis als je het lokaal uitvoert; Groq voegt kwaliteit toe aan verhaalvelden maar kost tokens en voegt latentie toe. Als je slechts één of twee niet-Engelse locales levert, is Groq-only voor de hele dictionary eenvoudiger dan het opbouwen van de routinglogica in shouldUseGroqForKey. De hybride complexiteit in translate.mjs betaalt alleen als je genoeg sleutels en genoeg locales hebt dat variatie in MT-kwaliteit daadwerkelijk belangrijk wordt.

Houd de Engelse bron canonieke en de uitvoerdiff-reviewbaar. De lockfile in scripts/translate.lock.json is geen optionele ceremonie; het is wat je in staat stelt om pnpm translate uit te voeren zonder tokens opnieuw uit te geven op secties die niet zijn veranderd. Elke pijplijn die de hele dictionary opnieuw vertaalt bij elke run, zal problemen met kosten en rate-limiting ondervinden voordat je portfolio een tweede lezer heeft. Sla de gegenereerde locale bestanden op zodat je elke vertaalverandering kunt beoordelen in een normale pull request.

Implementeer vervolgens één Engelse bron, gegenereerde satellietbestanden, en scripts die je kunt uitvoeren in CI of lokaal, zodat je portfolio een product blijft dat je kunt evolueren, geen stapel handmatig bewerkte kopieën.

Samenvatting

De implementatie is niet het moeilijke deel. LOCALE_CONFIG, twee batchscripts, een lockfile: elke fatsoenlijke ingenieur kan dat in een weekend bouwen. Het moeilijkere deel is om vooraf te beslissen dat taal een productbeperking is, geen functie die je toevoegt als de rest “klaar” is.

Als je i18n als decoratie behandelt, zul je het precies één keer leveren en nooit meer aanraken. Als je het als scope behandelt, met automatisering, controleerbare diffs en een eerlijke lijst van wat het niet dekt, en het blijft onderhoudbaar, en je blijft in controle over wat je portfolio communiceert aan lezers die je misschien nooit in het Engels ontmoet.

Dat is de daadwerkelijke weddenschap hier: niet dat elke zin klinkt alsof een menselijke vertaler een uur aan het werk was, maar dat de site werkt voor iemand in Warschau of Tallinn die liever niet in het Engels leest als het ertoe doet. Of die weddenschap succesvol is, is nog te zien.