

1/1/1970
Πώς Υλοποίησα i18n σε 20 Γλώσσες σε 3 Μέρες
Γεια χαρά! Μόλις ολοκλήρωσα ένα τεράστιο έργο όπου μετέφρασα το Foony σε 20 διαφορετικές γλώσσες. Ήταν ένα μεγαλόπνοο εγχείρημα που απαιτούσε πειραγμα σχεδόν κάθε αρχείου στον κώδικα, αλλά κατάφερα να το ολοκληρώσω σε μόλις 3 μέρες.
Παρακάτω θα αναλύσω πώς το έκανα, τους συγκεκριμένους αριθμούς πίσω από την αλλαγή, και γιατί αποφάσισα να φτιάξω τη δική μου βιβλιοθήκη μετάφρασης (για ακόμα μια φορά) αντί να χρησιμοποιήσω το industry standard.
Γιατί όχι το i18next;
Όταν πρωτοκοίταξα να προσθέσω μεταφράσεις, σκέφτηκα το industry standard: τα i18next και react-i18next.
Αντ' αυτού, αποφάσισα να βελτιστοποιήσω για συντηρησιμότητα από AI. Το i18next είναι ισχυρό, αλλά η ποικιλία του API του μπορεί να κάνει τα LLMs να παραισθάνονται ή να γράφουν ασυνεπή κώδικα. Περιορίζοντας τη βιβλιοθήκη σε ένα απλό t() και interpolate(), εξασφάλισα ότι 10+ παράλληλοι agents μπορούσαν να γράψουν 100% type-safe κώδικα με σχεδόν μηδενική ανθρώπινη παρέμβαση.
Ήμουν επίσης επιφυλακτικός με το να μπω σε ένα μεγάλο οικοσύστημα που μπορεί να εισάγει breaking changes αργότερα. Έχοντας καεί από επώδυνες μεταναστεύσεις όπως React Router v5 και MUI v4 → v5, ξέρω ότι οι ραγδαίες παραβιάσεις backwards-compatibility είναι πολύ συχνές στον κόσμο της JavaScript. Το κόστος προσθήκης χαρακτηριστικών pluralization αργότερα είναι μικρότερο από το κόστος της χειροκίνητης μετανάστευσης 139k γραμμών κώδικα τώρα.
Ήθελα κάτι εξαιρετικά απλό, υπερβολικά ελαφρύ, και προσαρμοσμένο ακριβώς στις ανάγκες της ομάδας μου.
Έτσι έγραψα το δικό μου.
Έφτιαξα ένα περιορισμένο υποσύνολο 3 KB ειδικά σχεδιασμένο για να επιτρέπει υψηλής ακρίβειας, αυτόνομο AI refactoring. Αυτό μου επέτρεψε να ενεργήσω ως ένας μοναδικός μηχανικός που ολοκληρώνει τον φόρτο εργασίας 3 εβδομάδων μιας ομάδας 5 ατόμων σε μόλις 3 μέρες.
Η Custom Υλοποίηση
Σκέφτηκα μια ελάχιστη βιβλιοθήκη i18n που είναι περίπου 3 KB gzipped. Εκθέτει δύο βασικές συναρτήσεις: την getTranslation() για non-React contexts και ένα useTranslation() hook για components.
Αυτές επιστρέφουν t() για απλή αντικατάσταση string και interpolate() για όταν χρειάζεται να εισάγω React components σε ένα translation string (όπως ένα link ή ένα εικονίδιο). Και οι δύο συναρτήσεις υποστηρίζουν αντικατάσταση μεταβλητών, π.χ. "Hello {{thing}}", {thing: 'World'}.
Τα keys ακολουθούν μια σημειογραφία "slash-dot" (slashes για το file path προς το αρχείο localization, dots για nested objects μέσα στο αρχείο). Για να διασφαλιστεί η μοναδικότητα, τα translation keys σε ένα αρχείο δεν μπορούν να έχουν forward-slashes.
Εδώ είναι η βασική συνάρτηση t():
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;
}
Και το 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]);
}
Ο πυρήνας ολόκληρης της βιβλιοθήκης είναι μόλις περίπου 580 γραμμές κώδικα. Διαχειρίζεται:
- Lazy-loading των translation files ώστε να μην στέλνουμε και τις 20 γλώσσες σε κάθε χρήστη.
- Code-splitting μεταφράσεων ανά "namespace" (π.χ.
common,misc,games/{gameId}). - Ένα "debug" locale που δείχνει τα raw keys ώστε να μπορώ να επαληθεύσω ότι όλα είναι σωστά συνδεδεμένα.
Για να διασφαλίσω ότι το σύστημα παραμένει εύκολο στη συντήρηση, πρόσθεσα επίσης εκτενή τεκμηρίωση στο shared/src/i18n/README.md, που καλύπτει τα πάντα από τη δομή των αρχείων μέχρι παραδείγματα χρήσης τόσο για client όσο και για server. Καθώς δεν χρησιμοποιώ μια standard βιβλιοθήκη, το να έχω αυτή την αναφορά είναι κρίσιμο για την ένταξη νέων μελών της ομάδας (ή απλά για να θυμίσω στον μελλοντικό εαυτό μου ή στα LLMs πώς λειτουργεί).
Σε Νούμερα
Για να σας δώσω μια αίσθηση της κλίμακας αυτής της ενημέρωσης, να τι άλλαξε στον κώδικα:
- 20 γλώσσες υποστηρίζονται (συν ένα debug locale για dev).
- 360 locale files δημιουργήθηκαν.
- 139.031 γραμμές μεταφραστικού κώδικα.
- 3.938 κλήσεις στην
t()προστέθηκαν σε όλο τον client. - 728 source files τροποποιήθηκαν.
- 18 Αγγλικά source files που χρησιμεύουν ως πηγή αλήθειας (16 παιχνίδια + common + misc).
Ενορχήστρωση με Agents
Το να γίνει αυτό χειροκίνητα θα χρειαζόταν μήνες αποβλακωτικής, μηχανικής δουλειάς. Αντ' αυτού, ενορχήστρωσα πάνω από μια ντουζίνα Cursor agents ταυτόχρονα για να κάνουν τη βαριά δουλειά.
Ξεκίνησα διασπώντας τον κώδικα σε "ενότητες" βάσει φακέλων. Κάθε παιχνίδι στο Foony είχε τον δικό του φάκελο και το δικό του translation namespace. Αυτό κρατάει το αρχικό μέγεθος φόρτωσης μικρό αφού φορτώνεις μόνο τις μεταφράσεις για το παιχνίδι που παίζεις.
Έτρεξα πολλούς Cursor agents ταυτόχρονα. Ανέθεσα σε κάθε agent μια συγκεκριμένη ενότητα, όπως "μετέτρεψε το παιχνίδι Chess για να χρησιμοποιεί μεταφράσεις", και αυτός περνούσε αρχείο προς αρχείο, βρίσκοντας user-facing strings και αντικαθιστώντας τα με t('games/chess/some.key').
Ο agent στη συνέχεια πρόσθετε αυτό το key στο κατάλληλο English locale file με ένα JSDoc σχόλιο που εξηγούσε το "τι" και το "πού" του string. Αυτό το context είναι σημαντικό όταν παράγονται οι μεταφράσεις για άλλες γλώσσες, καθώς βοηθάει το LLM να καταλάβει αν "Save" σημαίνει "Save Game Configuration" ή "Save Your Draw & Guess Drawing".
Έλεγχος Ποιότητας
Έλεγξα γρήγορα όλον τον κώδικα που παράχθηκε. Οι agents ήταν εκπληκτικά καλοί, αλλά έκαναν περιστασιακά λάθη, όπως το να βάζουν το useTranslation hook μετά από ένα early return statement.
Οι strongly-typed μεταφράσεις βοήθησαν αφάνταστα. Αυτό διασφάλιζε ότι όλες οι μεταφράσεις για κάθε locale είχαν όλα τα σωστά keys (και κανένα από τα λάθος). Επίσης διασφάλιζε ότι οι κλήσεις στις t() και interpolate() χρησιμοποιούσαν πραγματικά translation strings που υπήρχαν.
Το type system εξάγει όλα τα πιθανά translation keys από τα αγγλικά source files:
/**
* 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
Αυτό δίνει τέλειο TypeScript autocomplete, και κάθε typo σε ένα translation key πιάνεται κατά το compile time. Οι agents δεν μπορούν να κάνουν λάθη όπως t('games/ches/name') γιατί το TypeScript το επισημαίνει αμέσως.
Τοπικοποίηση
Μόλις ολοκληρώθηκε η μετατροπή στα Αγγλικά, διαχώρισα τις υπόλοιπες εργασίες locale. Έκανα κάθε agent υπεύθυνο για τη μετατροπή ενός μόνο αγγλικού locale file σε μια καθορισμένη γλώσσα.
Για παράδειγμα, έδωσα στους agents ένα prompt σαν αυτό:
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.
Σκέφτηκα να βάλω το Cursor να φτιάξει ένα script που θα τάιζε καθένα από αυτά τα αρχεία σε ένα LLM και θα το έβαζε να παράγει τα πράγματα, αλλά ήθελα να εξοικονομήσω λίγο στο κόστος του LLM. Η χρήση ενός script για ενημέρωση μόνο των ελλειπόντων μεταφράσεων ήταν η καλύτερη προσέγγιση, και πιθανότατα θα χρησιμοποιήσω παρόμοια λύση στο μέλλον. Θα ήθελα να παρακολουθώ ποια strings χρειάζονται ενημέρωση / μετάφραση, αλλά θέλω να κρατήσω τα πράγματα απλά. Ίσως μεταφέρω τη μεταφραστική δουλειά σε μια βάση δεδομένων ή κάτι τέτοιο.
Πρόσθεσα επίσης ένα "debug" locale που είναι διαθέσιμο μόνο στην ανάπτυξη. Αυτό μου επιτρέπει να βλέπω όλα τα αντικατεστημένα strings για να επαληθεύω ότι τα πράγματα δουλεύουν (συν, νομίζω είναι κουλ). Όταν χρησιμοποιείς το debug locale, η t() επιστρέφει το key τυλιγμένο σε αγκύλες:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Έτσι αντί να βλέπεις "Welcome to Foony!", θα έβλεπες ⟦welcome⟧, κάνοντας εύκολο να εντοπίσεις τυχόν ελλείπουσες μεταφράσεις.
Τέλος, ένας άλλος agent υλοποίησε routing /{locale}/** ώστε πράγματα όπως /ja/games/chess να κατευθύνονται στη σωστή γλώσσα (στην προκειμένη περίπτωση Ιαπωνικά).
Μετάφραση του Blog
Η μετάφραση των UI strings ήταν ένα πράγμα, αλλά τι γίνεται με τα blog posts; Δεν ήθελα να ξεκινήσω και να διαχειριστώ ακόμα περισσότερους agents για να μεταφράσω όλα τα blog posts μου.
Το έλυσα βάζοντας έναν agent να φτιάξει ένα script (scripts/src/generateBlogTranslations.ts) που αυτοματοποιεί ολόκληρη τη διαδικασία.
Έτσι λειτουργεί:
- Σαρώνει τον κατάλογο
client/src/posts/enγια αγγλικά αρχεία MDX. - Ελέγχει για ελλείπουσες μεταφράσεις στους άλλους φακέλους locale (π.χ.
posts/ja,posts/es). - Αν λείπει μια μετάφραση, διαβάζει το αγγλικό περιεχόμενο και το ταΐζει στο Gemini 3 Pro Preview με ένα συγκεκριμένο prompt για να μεταφράσει το περιεχόμενο διατηρώντας τη μορφοποίηση Markdown.
- Αποθηκεύει το νέο αρχείο στη σωστή τοποθεσία.
Στο frontend, χρησιμοποιώ import.meta.glob για να εισάγω δυναμικά όλα αυτά τα αρχεία MDX. Στη συνέχεια το component PostPage μου απλά ελέγχει το τρέχον locale του χρήστη και κάνει lazy-load το σωστό αρχείο MDX. Αν λείπει μια μετάφραση (επειδή δεν έχω τρέξει ακόμα το script), επιστρέφει χαριτωμένα στα Αγγλικά.
Μέρα 4: Αυτοματοποιημένη Δημιουργία Μεταφράσεων
Ήξερα ότι η αρχική λύση δεν επρόκειτο να κλιμακωθεί. Έτσι, τώρα που είχα βγάλει το i18n, ήταν ώρα να το εξοπλίσω λίγο με μια προσέγγιση οδηγούμενη από βάση δεδομένων.
Με λίγα λόγια: όταν άλλαζε αγγλικό κείμενο ή σχόλια JSDoc, οι μεταφράσεις έπρεπε να αναπαράγονται. Η χειροκίνητη παρακολούθηση του τι χρειαζόταν ενημέρωση θα ήταν επιρρεπής σε λάθη και σπατάλη χρόνου του developer.
Έτσι έχτισα τη λύση που είχα αρχικά σχεδιάσει: ένα σύστημα δημιουργίας μεταφράσεων που υποστηρίζεται από PostgreSQL.
Το Database Schema
Πρόσθεσα έναν πίνακα translations στη βάση δεδομένων PostgreSQL μας με την ακόλουθη δομή:
key: Το translation key σε σημειογραφία "slash-dot" (π.χ.,"games/yacht/nested.name","config.timeLimit.label").en_value: Η αγγλική τιμή πηγήςtarget_locale: Ο κωδικός του target locale (π.χ.,"es","fr","zh")target_value: Η μεταφρασμένη τιμήcontext: Ένα πεδίο JSONB που περιέχει JSDoc για αυτό το key και όλα τα ancestor keyscreated_atκαιupdated_at: Timestamps για παρακολούθηση
Το unique index είναι στο (key, target_locale, en_value, context). Αυτό είναι κρίσιμο: συμπεριλαμβάνοντας το context στο unique constraint, μπορούμε να ανιχνεύσουμε αυτόματα όταν αλλάζουν τα σχόλια JSDoc και να αναπαράγουμε τις μεταφράσεις. Οι παλιές μεταφράσεις διατηρούνται για ιστορική αναφορά.
Το Generation Script
Δημιούργησα το scripts/src/generateLocalizations.ts που αυτοματοποιεί ολόκληρη τη ροή εργασίας μετάφρασης:
- Εξάγει τα αγγλικά keys: Χρησιμοποιεί AST parsing (ts-morph) για να εξαγάγει όλα τα translation keys από τα αρχεία
shared/src/i18n/locales/en/**, επεξεργαζόμενο μόνο default exports - Εξάγει το JSDoc context: Αναλύει τα σχόλια JSDoc για κάθε key και όλα τα ancestor keys (parent objects) για να παρέχει πλούσιο context
- Ερωτά τη βάση δεδομένων: Ελέγχει τις υπάρχουσες μεταφράσεις στην PostgreSQL, ταιριάζοντας σε
key,target_locale,en_value, ΚΑΙcontext. Αν αλλάξει οτιδήποτε από αυτά, η μετάφραση αναπαράγεται. - Εντοπίζει ελλείποντα/αλλαγμένα keys: Βρίσκει keys που χρειάζονται μετάφραση ή έχουν αλλαγμένες αγγλικές τιμές/σχόλια
- Ομαδοποιεί μεταφράσεις σε batches: Ομαδοποιεί ανά locale και namespace prefix για πιο αποδοτικές κλήσεις LLM (επίσης κάνει τις μεταφράσεις πιο γρήγορες). Αν το batch είναι πολύ μεγάλο όμως, η ποιότητα της μετάφρασης θα χειροτερέψει.
- Παράγει μεταφράσεις: Χρησιμοποιεί GPT 5.1 με εκτενές context (JSDoc, γλώσσα+περιοχή, τόνος, glossary, παραδείγματα). Έχω διαβάσει ότι το 5.1 είναι καλύτερο από το 5.2 για γράψιμο (δεν ακούγεται ανούσιο), αλλά δεν το έχω επιβεβαιώσει.
- QA checks: Επικυρώνει τη διατήρηση placeholders, π.χ.
{{name}}, την ακεραιότητα των keys, τη μορφή JSON - Αποθηκεύει στη βάση δεδομένων: Σώζει μεταφράσεις με πλήρες context (JSDoc + ancestor JSDoc)
- Παράγει αρχεία locale: Διαβάζει από τη βάση δεδομένων και γράφει σωστά μορφοποιημένα TypeScript locale files με τύπους
RecursivePartial
Βασικά Οφέλη
Αυτή η προσέγγιση μας δίνει αρκετές βελτιώσεις DevEx:
- Αυτόματη αναπαραγωγή: Όταν αλλάζει αγγλικό κείμενο Ή σχόλια JSDoc, οι μεταφράσεις αναπαράγονται αυτόματα. Έτσι αν κάποιος πει ότι μια μετάφραση είναι κακή, είναι πραγματικά εύκολο να αναπαραχθούν οι μεταφράσεις παρέχοντας περισσότερο context ως σχόλιο.
- Πλούσιο context: Τα σχόλια JSDoc παρέχουν context μετάφρασης (π.χ., "Error message shown to players, max 15 characters"), βοηθώντας το LLM να παράγει πιο ακριβείς μεταφράσεις
- Ancestor context: Το JSDoc των parent objects παρέχει namespace context (π.χ., "Achievement for being in a game where all eggs are destroyed"), δίνοντας λίγη παραπάνω σαφήνεια
- Ιστορική παρακολούθηση: Οι παλιές μεταφράσεις αποθηκεύονται στη βάση δεδομένων. Δεν πιάνουν πολύ χώρο, οπότε δεν βλέπω πολύ λόγο να τις διαγράψω προς το παρόν, και είναι κουλ να βλέπεις την ιστορία.
Τεχνικές Λεπτομέρειες
Η υλοποίηση χρησιμοποιεί αρκετές τεχνικές για να διασφαλίσει αξιοπιστία και αποδοτικότητα:
- AST-based εξαγωγή για να διασφαλίσω ότι παίρνω τα σωστά σχόλια
- Παράλληλη επεξεργασία χρησιμοποιώντας Semaphore για ταυτόχρονη μετάφραση batch
- Λογική επανάληψης exponential backoff για αποτυχίες API. Οι κλήσεις LLM είναι περιβόητα ασταθείς.
Το script μπορεί να εκτελεστεί με npm run generate-localizations από τον κατάλογο scripts. Συνδέεται στην PostgreSQL και επεξεργάζεται όλες τις ελλείπουσες ή αλλαγμένες μεταφράσεις για όλα τα υποστηριζόμενα locales όταν εκτελείται.
Συμπέρασμα
Σε αυτό το σημείο, είχα ένα πλήρως λειτουργικό site μεταφρασμένο σε όλα τα 20 locales!
Ήταν 3 τρελές μέρες, αλλά το αποτέλεσμα είναι ένα πλήρως τοπικοποιημένο site που νιώθει (κυρίως) εγγενές για τους χρήστες σε όλον τον κόσμο. Χτίζοντας μια custom, ελαφριά βιβλιοθήκη και αξιοποιώντας AI agents για την κουραστική δουλειά του refactoring, κατάφερα αυτό που θα ήταν αδύνατο μόλις έναν χρόνο πριν: πλήρες i18n σε 3 μέρες για ένα σύνθετο website από 1 μηχανικό. Το μέλλον του προγραμματισμού δεν είναι το να γράφεις κώδικα γρήγορα. Είναι το να ενορχηστρώνεις AI agents και να κατέχεις τη βαθιά τεχνογνωσία του τομέα για να επαληθεύεις τα αποτελέσματά τους.