background blurbackground mobile blur

1/1/1970

Πώς υλοποίησα i18n σε 20 γλώσσες μέσα σε 3 μέρες

Γεια χαρά! Μόλις τελείωσα μια τεράστια αποστολή: μετέφρασα το Foony σε 20 διαφορετικές γλώσσες. Ήταν ένα γιγάντιο project που με ανάγκασε να πειράξω σχεδόν κάθε αρχείο στο codebase, αλλά κατάφερα να τα κάνω όλα μέσα σε μόλις 3 μέρες.

Παρακάτω θα σου δείξω πώς το έκανα, τους συγκεκριμένους αριθμούς πίσω από την αλλαγή και γιατί αποφάσισα να φτιάξω ξανά δική μου βιβλιοθήκη μεταφράσεων αντί να χρησιμοποιήσω το industry standard.

Γιατί όχι i18next;

Όταν πρωτοκοίταξα την προσθήκη μεταφράσεων, σκέφτηκα το industry standard: i18next και react-i18next.

Αντί γι’ αυτό, αποφάσισα να βελτιστοποιήσω για συντηρησιμότητα από AI. Το i18next είναι δυνατό, αλλά η ποικιλία του API του μπορεί να οδηγήσει τα LLMs στο να "παραληρούν" ή να γράφουν ασυνεπή κομμάτια κώδικα. Περιορίζοντας τη βιβλιοθήκη σε ένα απλό t() και interpolate(), εξασφάλισα ότι 10+ παράλληλοι agents μπορούσαν να γράφουν 100% type-safe code με σχεδόν μηδενική ανθρώπινη παρέμβαση.

Ήμουν επίσης διστακτικός στο να δεσμευτώ σε ένα μεγάλο οικοσύστημα που μπορεί αργότερα να φέρει breaking changes. Αφού έχω ήδη καεί από επίπονες μεταβάσεις όπως React Router v5 και MUI v4 → v5, ξέρω καλά ότι το να σπάνε γρήγορα το backwards-compatibility είναι υπερβολικά συχνό στον κόσμο της JavaScript. Το κόστος του να προσθέσω αργότερα features για πληθυντικούς είναι μικρότερο από το κόστος του να μεταφέρω τώρα χειροκίνητα 139k γραμμές κώδικα.

Ήθελα κάτι απίστευτα απλό, εξαιρετικά ελαφρύ και κομμένο και ραμμένο ακριβώς στις ανάγκες της ομάδας μου.

Οπότε έγραψα τη δική μου.

Έφτιαξα ένα περιορισμένο subset ~3 KB, σχεδιασμένο ειδικά για να επιτρέπει υψηλής ακρίβειας, αυτόνομο AI refactoring. Αυτό μου επέτρεψε, ως ένας μόνο engineer, να βγάλω φόρτο δουλειάς 5μελής ομάδας για 3 εβδομάδες μέσα σε μόλις 3 μέρες.

Η custom υλοποίηση

Κατέληξα σε μια μίνι i18n βιβλιοθήκη περίπου 3 KB gzipped. Εκθέτει δύο βασικές συναρτήσεις: getTranslation() για non-React περιβάλλοντα και ένα hook useTranslation() για components.

Αυτές επιστρέφουν την t() για απλή αντικατάσταση string και την interpolate() όταν χρειάζομαι να βάλω React components μέσα σε ένα translation string (όπως ένα link ή ένα icon). Και οι δύο συναρτήσεις υποστηρίζουν αντικατάσταση μεταβλητών, π.χ. "Hello {{thing}}", {thing: 'World'}.

Να η βασική συνάρτηση t():

export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
  let namespace: string = '';
  let translationKey: string = key;
  
  // Έλεγχος αν το key περιέχει '/' - αυτό υποδηλώνει 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;
}

Και το React hook:

export function useTranslation() {
  const [language] = useLanguage();
  
  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]);
}

Η καρδιά όλης της βιβλιοθήκης είναι μόλις περίπου 580 γραμμές κώδικα. Αναλαμβάνει να:

  • κάνει lazy-load τα translation files ώστε να μη στέλνουμε και τις 20 γλώσσες σε κάθε χρήστη
  • κάνει code-splitting τα translations ανά "namespace" (π.χ. common, misc, games/{gameId})
  • παρέχει ένα "debug" locale που δείχνει τα raw keys ώστε να μπορώ να ελέγχω ότι όλα είναι σωστά δεμένα

Για να μείνει το σύστημα εύκολο στη συντήρηση, πρόσθεσα και αναλυτική τεκμηρίωση στο shared/src/i18n/README.md, που καλύπτει τα πάντα, από τη δομή των αρχείων μέχρι παραδείγματα χρήσης για client και server. Επειδή δεν χρησιμοποιώ standard βιβλιοθήκη, αυτό το reference είναι κρίσιμο για να onboardάρω νέα μέλη στην ομάδα (ή για να θυμίζω στον μελλοντικό μου εαυτό ή στα LLMs πώς δουλεύει).

Τα νούμερα

Για να πάρεις μια ιδέα για το μέγεθος του update, ορίστε τι άλλαξε στο codebase:

  • 20 υποστηριζόμενες γλώσσες (συν ένα debug locale για dev).
  • 360 locale files δημιουργήθηκαν.
  • 139.031 γραμμές κώδικα μεταφράσεων.
  • 3.938 κλήσεις σε t() προστέθηκαν σε όλο τον client.
  • 728 source files τροποποιήθηκαν.
  • 18 αγγλικά source files που λειτουργούν ως source of truth (16 games + common + misc).

Ορχήστρωση με agents

Αν το έκανα όλο αυτό με το χέρι, θα μου έπαιρνε μήνες από βαρετή, μηχανική δουλειά. Αντί γι’ αυτό, οργάνωσα πάνω από μια ντουζίνα Cursor agents να τρέχουν ταυτόχρονα και να κάνουν τη βαριά δουλειά.

Ξεκίνησα σπάζοντας το codebase σε "sections" με βάση τα folders. Κάθε παιχνίδι στο Foony πήρε το δικό του folder και το δικό του translation namespace. Έτσι το αρχικό load μένει μικρό, γιατί φορτώνεις μόνο τα translations για το παιχνίδι που παίζεις.

Έτρεξα πολλούς Cursor agents ταυτόχρονα. Σε κάθε agent έδινα ένα συγκεκριμένο section, π.χ. "κάνε το παιχνίδι Chess να χρησιμοποιεί translations", και εκείνος περνούσε αρχείο προς αρχείο, έβρισκε τα user-facing strings και τα αντικαθιστούσε με t('games/chess/some.key').

Ο agent μετά πρόσθετε αυτό το key στο κατάλληλο αγγλικό locale file με ένα JSDoc comment που εξηγούσε το "τι" και το "πού" του string. Αυτό το context είναι σημαντικό όταν φτιάχνεις τις μεταφράσεις για άλλες γλώσσες, γιατί βοηθά το LLM να καταλάβει αν το "Save" σημαίνει "Αποθήκευση ρυθμίσεων παιχνιδιού" ή "Αποθήκευση του σχεδίου σου στο Draw & Guess".

Έλεγχος ποιότητας

Πέρασα γρήγορα όλο τον παραγόμενο κώδικα από review. Οι agents ήταν εκπληκτικά καλοί, αλλά έκαναν και μερικά περιστασιακά λάθη, όπως το να βάζουν το hook useTranslation μετά από ένα early return.

Τα strongly-typed translations βοήθησαν απίστευτα. Εξασφάλισαν ότι όλες οι μεταφράσεις για κάθε locale είχαν όλα τα σωστά keys (και κανένα λάθος). Επίσης εγγυήθηκαν ότι οι κλήσεις σε t() και interpolate() χρησιμοποιούσαν υπαρκτά translation strings.

Το type system βγάζει όλα τα πιθανά translation keys από τα αγγλικά source files:

/**
 * Εξάγει όλα τα πιθανά paths από ένα nested object type, δημιουργώντας keys σε dot-notation.
 * Παράδειγμα: {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

Αυτό δίνει τέλειο autocomplete στο TypeScript και οποιοδήποτε typo σε translation key εντοπίζεται στο compile time. Οι agents δεν μπορούν να κάνουν λάθη τύπου t('games/ches/name'), γιατί το TypeScript το μαρκάρει αμέσως.

Τοπικοποίηση

Μόλις τελείωσε η μετατροπή στα αγγλικά locales, έσπασα τις υπόλοιπες δουλειές ανά γλώσσα. Έκανα κάθε agent υπεύθυνο για να μετατρέψει ένα συγκεκριμένο αγγλικό locale file σε μια συγκεκριμένη γλώσσα.

Για παράδειγμα, έδινα στους agents ένα prompt σαν αυτό:

Βεβαιώσου ότι το ar/games/dinomight.ts έχει όλες τις μεταφράσεις από το en/games/dinomight.ts.
Χρησιμοποίησε `export const account: DinomightTranslations = {`.
Επανάλαβε μέχρι να μην υπάρχουν άλλα type errors για το δικό σου translation file (αν δεις errors για άλλα αρχεία, αγνόησέ τα--τρέχεις παράλληλα με άλλους agents που είναι υπεύθυνοι για αυτά τα αρχεία).
Οι μεταφράσεις σου πρέπει να είναι εξαιρετικές και σωστές σε σχέση με το jsdoc context που δίνεται στο en.
Πρέπει να το κάνεις αυτό χειροκίνητα και χωρίς να γράψεις "helper" scripts, και χωρίς καθόλου shortcuts.

Σκέφτηκα να βάλω το Cursor να φτιάξει ένα script που θα έστελνε κάθε ένα από αυτά τα αρχεία σε ένα LLM και θα άφηνα εκείνο να παράγει τις μεταφράσεις, αλλά ήθελα να γλιτώσω λίγο από το κόστος των LLMs. Το να χρησιμοποιήσω ένα script μόνο για να ενημερώνει τις ελλιπείς μεταφράσεις ήταν η καλύτερη προσέγγιση, και μάλλον θα κάνω κάτι παρόμοιο και στο μέλλον. Θα ήθελα να παρακολουθώ ποια strings χρειάζονται update / μετάφραση, αλλά θέλω να το κρατήσω απλό. Ίσως μεταφέρω τη δουλειά των μεταφράσεων σε μια βάση δεδομένων ή κάτι τέτοιο.

Πρόσθεσα επίσης ένα "debug" locale που είναι διαθέσιμο μόνο σε development. Αυτό μου επιτρέπει να βλέπω όλα τα αντικαταστημένα strings για να τσεκάρω ότι όλα δουλεύουν (συν το ότι είναι απλώς cool). Όταν χρησιμοποιείς το debug locale, η t() επιστρέφει το key μέσα σε brackets:

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

Οπότε αντί να βλέπεις "Welcome to Foony!", θα βλέπεις ⟦welcome⟧, κάτι που κάνει πολύ εύκολο να εντοπίσεις τυχόν ελλιπείς μεταφράσεις.

Τέλος, ένας άλλος agent υλοποίησε routing τύπου /{locale}/** ώστε διαδρομές όπως το /ja/games/chess να οδηγούν στη σωστή γλώσσα (σε αυτή την περίπτωση ιαπωνικά).

Μετάφραση του blog

Το να μεταφράσω τα UI strings ήταν το ένα κομμάτι, αλλά τι γίνεται με τα blog posts; Δεν ήθελα να σηκώσω και να διαχειρίζομαι ακόμα περισσότερους agents μόνο και μόνο για να μεταφράσουν όλα τα posts μου.

Το έλυσα βάζοντας έναν agent να φτιάξει ένα script (scripts/src/generateBlogTranslations.ts) που αυτοματοποιεί όλη τη διαδικασία.

Να πώς δουλεύει:

  1. Σκανάρει το client/src/posts/en directory για αγγλικά MDX files.
  2. Ελέγχει για ελλιπείς μεταφράσεις στα υπόλοιπα locale folders (π.χ. posts/ja, posts/es).
  3. Αν λείπει κάποια μετάφραση, διαβάζει το αγγλικό περιεχόμενο και το στέλνει στο Gemini 3 Pro Preview με ένα συγκεκριμένο prompt ώστε να μεταφράσει το περιεχόμενο αλλά να κρατήσει το Markdown formatting.
  4. Αποθηκεύει το νέο αρχείο στη σωστή τοποθεσία.

Στο frontend, χρησιμοποιώ import.meta.glob για να κάνω dynamic import όλα αυτά τα MDX files. Το component PostPage μετά απλώς κοιτάζει το τρέχον locale του χρήστη και κάνει lazy-load το σωστό MDX file. Αν λείπει κάποια μετάφραση (επειδή δεν έχω τρέξει ακόμα το script), κάνει ήρεμα fallback στα αγγλικά.

Συμπέρασμα

Σε αυτό το σημείο είχα ένα πλήρως λειτουργικό site μεταφρασμένο και στις 20 γλώσσες!

Ήταν τρεις μέρες σκέτη τρέλα, αλλά το αποτέλεσμα είναι ένα πλήρως localized site που φαίνεται (σχεδόν) native στους χρήστες σε όλο τον κόσμο. Φτιάχνοντας μια custom, ελαφριά βιβλιοθήκη και εκμεταλλευόμενος AI agents για την κουραστική refactoring δουλειά, κατάφερα κάτι που θα ήταν αδύνατο πριν από μόλις έναν χρόνο: full i18n σε 3 μέρες για ένα πολύπλοκο site από 1 μόνο engineer. Το μέλλον του προγραμματισμού δεν είναι να γράφεις κώδικα γρήγορα. Είναι να οργανώνεις AI agents και να έχεις τόσο βαθιά γνώση του domain σου ώστε να μπορείς να ελέγχεις την έξοδό τους.

8 Ball Pool online multiplayer billiards icon