background blurbackground mobile blur

1/1/1970

Paano Ko Na-implement ang i18n sa 20 Wika sa Loob ng 3 Araw

Kumusta! Katatapos ko lang ng isang malaking proyekto kung saan isinalin ko ang Foony sa 20 magkakaibang wika. Napakalaking trabaho nito na halos lahat ng file sa codebase ay kinailangang baguhin, pero nagawa ko ito sa loob lang ng 3 araw.

Sa baba, ipapaliwanag ko kung paano ko ginawa, ang mga partikular na numero sa likod ng pagbabago, at kung bakit nagdesisyon akong gumawa ng sarili kong translation library (muli) sa halip na gamitin ang industry standard.

Bakit hindi i18next?

Noong una kong tiningnan ang pagdaragdag ng mga translation, isinaalang-alang ko ang industry standard: i18next at react-i18next.

Sa halip, nagpasya akong i-optimize para sa maintainability ng AI. Makapangyarihan ang i18next, pero ang dami ng API nito ay maaaring magdulot ng hallucination o hindi consistent na code mula sa mga LLM. Sa pamamagitan ng paglilimita sa library sa simpleng t() at interpolate(), nasiguro ko na 10+ parallel agents ang makakapagsulat ng 100% type-safe na code na halos walang interbensyon mula sa tao.

Nag-aalangan din ako na pumasok sa isang malaking ecosystem na maaaring magdala ng breaking changes mamaya. Dahil nasaktan na ako sa mga masakit na migration tulad ng React Router v5 at MUI v4 → v5, alam ko na napakakaraniwan ng mabilis na pagsira ng backwards-compatibility sa JavaScript-land. Mas mababa ang gastos sa pagdaragdag ng mga pluralization feature mamaya kaysa sa gastos ng manu-manong pag-migrate ng 139k na linya ng code ngayon.

Gusto ko ng sobrang simple, sobrang lightweight, at ginawa partikular para sa pangangailangan ng team ko.

Kaya gumawa ako ng sarili ko.

Bumuo ako ng 3 KB na constrained subset na partikular na dinisenyo para mapagana ang high-accuracy at autonomous na AI refactoring. Ito ang nagbigay-daan sa akin na kumilos bilang nag-iisang engineer na gumagawa ng 3-linggong workload ng 5-katao na team sa loob lang ng 3 araw.

Ang Custom na Implementation

Gumawa ako ng minimal na i18n library na umaabot sa mga 3 KB gzipped. Naglalabas ito ng dalawang pangunahing function: getTranslation() para sa mga non-React context at useTranslation() hook para sa mga component.

Nagbabalik ang mga ito ng t() para sa simpleng string replacement at interpolate() para kapag kailangan kong mag-inject ng React component sa isang translation string (tulad ng link o icon). Sinusuportahan ng parehong function ang variable replacement, hal. "Hello {{thing}}", {thing: 'World'}.

Sumusunod ang mga key sa "slash-dot" notation (slash para sa file path papunta sa localization file, dot para sa mga nested object sa file). Para masiguro ang pagiging unique, ang mga translation key sa isang file ay hindi maaaring may forward-slash.

Narito ang core na t() function:

export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
  let namespace: string = '';
  let translationKey: string = key;
  
  // Check if key contains '/' - this indicates a namespace
  const slashIndex = key.indexOf('/');
  if (slashIndex !== -1) {
    const parts = key.split('/');
    namespace = parts.slice(0, -1).join('/');
    translationKey = parts[parts.length - 1];
  }
  
  const targetLocale = locale ?? currentLocale;
  const text = getTranslationValue(targetLocale, namespace, translationKey);
  
  if (values) {
    return interpolateString(text, values);
  }
  
  return text;
}

At ang React hook:

export function useTranslation() {
  const [language] = useLanguage();

  // Subscribe to locale loading events to trigger re-renders when translations are loaded
  const version = useSyncExternalStore(
    (callback) => LocaleQueryer.onLoad(callback),
    () => LocaleQueryer.getVersion(),
    () => LocaleQueryer.getVersion()
  );

  return useMemo(() => ({
    t: (key: TranslationKeys, values?: Record<string, string | number>) => 
      t(key, values, language),
    interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) => 
      interpolate(key, components, language),
  }), [language, version]);
}

Ang core ng buong library ay mga 580 lines of code lang. Hinahawakan nito ang:

  • Lazy-loading ng mga translation file para hindi natin ipadala ang lahat ng 20 wika sa bawat user.
  • Code-splitting ng mga translation ayon sa "namespace" (hal. common, misc, games/{gameId}).
  • Isang "debug" locale na nagpapakita ng raw key para ma-verify ko na lahat ay tama ang pagkaka-wire.

Para masiguro na madaling i-maintain ang sistema, nagdagdag din ako ng comprehensive na documentation sa shared/src/i18n/README.md, na sumasaklaw sa lahat mula sa istraktura ng file hanggang sa mga halimbawa ng paggamit para sa parehong client at server. Dahil hindi ako gumagamit ng standard library, kritikal ang reference na ito para sa pag-onboard ng mga bagong miyembro ng team (o para lang ipaalala sa hinaharap kong sarili o sa mga LLM kung paano ito gumagana).

Ayon sa mga Numero

Para mabigyan ka ng ideya sa laki ng update na ito, narito ang mga nabago sa codebase:

  • 20 wika ang sinusuportahan (kasama ang debug locale para sa dev).
  • 360 locale file ang ginawa.
  • 139,031 na linya ng translation code.
  • 3,938 na tawag sa t() ang naidagdag sa client.
  • 728 source file ang nabago.
  • 18 na English source file na nagsisilbing source of truth (16 na laro + common + misc).

Pag-orchestrate gamit ang mga Agent

Kung ginawa ko ito nang manu-mano, aabutin ng buwan-buwan na nakakapagod at mekanikal na trabaho. Sa halip, nag-orchestrate ako ng mahigit isang dosenang Cursor agents nang sabay-sabay para gawin ang mabigat na bahagi ng trabaho.

Sinimulan ko sa pamamagitan ng paghahati-hati sa codebase sa "mga seksyon" batay sa mga folder. Bawat laro sa Foony ay may sariling folder at sariling translation namespace. Pinapanatili nitong maliit ang initial load size dahil ang mga translation lang para sa larong nilalaro mo ang ila-load.

Nagpatakbo ako ng maraming Cursor agent nang sabay-sabay. Itinalaga ko ang bawat agent sa isang partikular na seksyon, tulad ng "i-convert ang Chess game para gumamit ng translations," at dumadaan ito sa file por file, hinahanap ang user-facing strings at pinapalitan ng t('games/chess/some.key').

Pagkatapos ay idadagdag ng agent ang key na iyon sa angkop na English locale file na may JSDoc comment na nagpapaliwanag ng "ano" at "saan" ng string. Mahalaga ang context na ito kapag bumubuo ng mga translation para sa ibang wika, dahil tumutulong ito sa LLM na maintindihan kung ang "Save" ay nangangahulugang "Save Game Configuration" o "Save Your Draw & Guess Drawing".

Quality Control

Mabilis kong nirepaso ang lahat ng code na nabuo. Nakakagulat na magaling ang mga agent, pero may mga pagkakataong nagkakamali sila, tulad ng paglalagay ng useTranslation hook pagkatapos ng maagang return statement.

Malaking tulong ang strongly-typed na mga translation. Sinigurado nito na lahat ng translation para sa bawat locale ay may lahat ng tamang key (at wala sa mga maling key). Sinigurado din nito na ang mga tawag sa t() at interpolate() ay gumagamit ng tunay na translation string na umiiral.

Kinukuha ng type system ang lahat ng posibleng translation key mula sa mga English source file:

/**
 * Extracts all possible paths from a nested object type, creating dot-notation keys.
 * Example: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
 */
type ExtractPaths<T, Prefix extends string = ''> = T extends string
  ? Prefix extends '' ? never : Prefix
  : T extends object
  ? {
      [K in keyof T]: K extends string | number
        ? T[K] extends string
          ? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
          : ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
        : never
    }[keyof T]
  : never;

export type TranslationKeys = 
  | ExtractPaths<typeof import('./locales/en/index').default>
  | `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
  | `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
  | `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
  // ... and so on for all games

Nagbibigay ito ng perpektong TypeScript autocomplete, at ang anumang typo sa translation key ay nahuhuli sa compile time. Hindi makakagawa ang mga agent ng mga pagkakamali tulad ng t('games/ches/name') dahil agad itong i-flag ng TypeScript.

Localization

Nang matapos ang English conversion, hinati-hati ko ang natitirang mga locale task. Ginawa kong responsable ang bawat agent sa pag-convert ng isang English locale file sa isang tinukoy na wika.

Halimbawa, binigyan ko ang mga agent ng prompt tulad nito:

Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.

Naisip kong magpagawa sa Cursor ng script na magpa-feed ng bawat isa sa mga file na ito sa isang LLM at pagawain iyon, pero gusto kong makatipid nang kaunti sa LLM cost. Mas magandang approach ang paggamit ng script na mag-update lang ng mga nawawalang translation, at malamang gagamitin ko ang katulad na solusyon sa hinaharap. Gusto kong i-track kung aling mga string ang kailangang i-update o i-translate, pero gusto kong panatilihing simple ang mga bagay. Maaaring ilipat ko ang trabaho ng pagsasalin sa isang database o iba pa.

Nagdagdag din ako ng "debug" locale na available lamang sa development. Pinapayagan akong tingnan ang lahat ng pinalitan na string para ma-verify na gumagana ang mga bagay (at sa tingin ko cool din ito). Kapag ginamit mo ang debug locale, nagbabalik ang t() ng key na nakabalot sa bracket:

if (targetLocale === 'debug') {
  return `⟦${key}⟧`;
}

Kaya sa halip na makita ang "Welcome to Foony!", makikita mo ang ⟦welcome⟧, na ginagawang madali ang pagtukoy sa mga nawawalang translation.

Sa wakas, isa pang agent ang nag-implement ng /{locale}/** routing kaya ang mga bagay tulad ng /ja/games/chess ay magru-route sa tamang wika (sa kasong ito ay Japanese).

Pag-translate ng Blog

Iba ang pag-translate ng UI strings, pero paano naman ang mga blog post? Ayaw kong magpatakbo at mag-manage ng mas maraming agent para mag-translate ng lahat ng blog post ko.

Nilutas ko ito sa pamamagitan ng pagpapagawa sa isang agent ng script (scripts/src/generateBlogTranslations.ts) na nag-a-automate ng buong proseso.

Ganito ito gumagana:

  1. Sinasaklaw nito ang client/src/posts/en directory para sa mga English MDX file.
  2. Sinusuri nito kung may mga nawawalang translation sa ibang locale folders (hal. posts/ja, posts/es).
  3. Kung may nawawalang translation, binabasa nito ang English content at ipinapasok sa Gemini 3 Pro Preview na may partikular na prompt para i-translate ang content habang pinapanatili ang Markdown formatting.
  4. Sine-save nito ang bagong file sa tamang lokasyon.

Sa frontend, ginagamit ko ang import.meta.glob para dynamic na i-import ang lahat ng MDX file na ito. Ang aking PostPage component ay simpleng nagche-check ng kasalukuyang locale ng user at lazy-loads ang tamang MDX file. Kung may nawawalang translation (dahil hindi ko pa napatakbo ang script), maganda nitong binabalik sa English.

Araw 4: Automated Translation Generation

Alam kong hindi nag-i-scale ang orihinal na solusyon. Kaya, ngayong nailabas ko na ang i18n, panahon na para palakasin ito gamit ang database-driven na approach.

Sa madaling salita: kapag nagbago ang English text o JSDoc comment, kailangang ma-regenerate ang mga translation. Manu-manong pag-track ng kung ano ang kailangang i-update ay magiging error-prone at sayang sa oras ng developer.

Kaya binuo ko ang solusyon na orihinal kong pinlano: isang PostgreSQL-backed na translation generation system.

Ang Database Schema

Nagdagdag ako ng translations table sa aming PostgreSQL database na may sumusunod na istraktura:

  • key: Ang translation key sa "slash-dot" notation (hal., "games/yacht/nested.name", "config.timeLimit.label").
  • en_value: Ang English source value
  • target_locale: Ang target locale code (hal., "es", "fr", "zh")
  • target_value: Ang isinaling value
  • context: Isang JSONB field na naglalaman ng JSDoc para sa key na ito at lahat ng ancestor key
  • created_at at updated_at: Mga timestamp para sa pag-track

Ang unique index ay nasa (key, target_locale, en_value, context). Ito ay mahalaga: sa pamamagitan ng pagsasama ng context sa unique constraint, awtomatiko nating ma-detect kapag nagbabago ang mga JSDoc comment at ma-regenerate ang mga translation. Ang mga lumang translation ay pinapanatili para sa historical reference.

Ang Generation Script

Gumawa ako ng scripts/src/generateLocalizations.ts na nag-a-automate ng buong translation workflow:

  1. Kinukuha ang mga English key: Gumagamit ng AST parsing (ts-morph) para kunin ang lahat ng translation key mula sa mga shared/src/i18n/locales/en/** file, pinoproseso lamang ang mga default export
  2. Kinukuha ang JSDoc context: Pina-parse ang mga JSDoc comment para sa bawat key at lahat ng ancestor key (parent objects) para magbigay ng masaganang konteksto
  3. Ina-query ang database: Sinusuri ang mga umiiral na translation sa PostgreSQL, tinutugma sa key, target_locale, en_value, AT context - kung magbabago ang alinman sa mga ito, maa-regenerate ang translation.
  4. Tinutukoy ang nawawala/nabagong key: Hinahanap ang mga key na kailangang i-translate o may nabagong English value/comment
  5. Bina-batch ang mga translation: Gina-group ayon sa locale at namespace prefix para sa mas episyenteng LLM call (mas mabilis din ang mga translation). Gayunpaman, kung sobrang laki ng batch, lalala ang kalidad ng translation.
  6. Bumubuo ng mga translation: Gumagamit ng GPT 5.1 na may comprehensive na konteksto (JSDoc, language+region, tone, glossary, examples). Nabasa ko na mas magaling ang 5.1 kaysa 5.2 para sa pagsusulat (hindi tunog malabsa), pero hindi ko pa nakumpirma.
  7. QA checks: Vina-validate ang pagpapanatili ng placeholder, hal. {{name}}, integridad ng key, JSON format
  8. Sine-store sa database: Sine-save ang mga translation na may buong konteksto (JSDoc + ancestor JSDoc)
  9. Bumubuo ng mga locale file: Bumabasa mula sa database at sumusulat ng tamang format na TypeScript locale file na may RecursivePartial types

Mga Pangunahing Benepisyo

Nagbibigay sa atin ang approach na ito ng ilang DevEx improvements:

  • Awtomatikong regeneration: Kapag nagbabago ang English text O JSDoc comment, awtomatiko ang regeneration ng mga translation. Kaya kung may nagsasabing pangit ang translation, talagang madali na lang i-regenerate ang mga translation sa pamamagitan ng pagbibigay ng mas maraming konteksto bilang comment.
  • Masaganang konteksto: Nagbibigay ang mga JSDoc comment ng translation context (hal., "Error message shown to players, max 15 characters"), na tumutulong sa LLM na gumawa ng mas tumpak na mga translation
  • Ancestor context: Nagbibigay ang JSDoc ng parent object ng namespace context (hal., "Achievement for being in a game where all eggs are destroyed"), na nagbibigay ng mas maraming kalinawan
  • Historical tracking: Nai-save ang mga lumang translation sa database. Hindi sila gaanong kumukuha ng espasyo, kaya hindi ko nakikita ang dahilan para tanggalin sila sa ngayon, at cool tingnan ang history.

Mga Teknikal na Detalye

Gumagamit ang implementation ng ilang technique para masiguro ang reliability at efficiency:

  • AST-based extraction para masiguro na nakukuha ko ang tamang mga comment
  • Parallel processing gamit ang Semaphore para sa concurrent batch translation
  • Exponential backoff retry logic para sa mga API failure. Kilala ang LLM call sa pagiging hindi mapagkakatiwalaan.

Maaaring patakbuhin ang script gamit ang npm run generate-localizations mula sa scripts directory. Kumokonekta ito sa PostgreSQL at pinoproseso ang lahat ng nawawala o nabagong translation para sa lahat ng sinusuportahang locale kapag pinatakbo.

Konklusyon

Sa puntong ito, nagkaroon ako ng ganap na gumaganang site na isinalin sa lahat ng 20 locale!

Nakakabaliw na 3 araw ito, pero ang resulta ay isang ganap na localized na site na pakiramdam (kalimitan) ay native sa mga user sa buong mundo. Sa pamamagitan ng pagbuo ng custom at lightweight na library at paggamit ng AI agent para sa nakakapagod na refactoring work, nagawa ko ang isang bagay na magiging imposible isang taon lang ang nakakaraan: buong i18n sa loob ng 3 araw para sa isang masalimuot na website ng 1 engineer. Ang hinaharap ng programming ay hindi tungkol sa mabilis na pagsusulat ng code. Ito ay tungkol sa pag-orchestrate ng mga AI agent at pagkakaroon ng malalim na domain expertise para ma-verify ang kanilang output.

8 Ball Pool online multiplayer billiards icon