background blurbackground mobile blur

1/1/1970

ผมทำ i18n รองรับ 20 ภาษาภายใน 3 วันได้ยังไง

สวัสดีครับ! ผมเพิ่งเสร็จงานชิ้นใหญ่ที่แปล Foony เป็น 20 ภาษา เป็นงานช้างที่ต้องไปแตะแทบทุกไฟล์ในโค้ดเบส แต่ผมก็ทำเสร็จได้ภายในเวลาแค่ 3 วัน

ด้านล่างนี้ผมจะเล่าให้ฟังว่าทำยังไง พร้อมตัวเลขเฉพาะเจาะจงเบื้องหลังการเปลี่ยนแปลงครั้งนี้ และทำไมผมถึงตัดสินใจเขียนไลบรารีแปลภาษาของตัวเอง (อีกแล้ว) แทนที่จะใช้ตัวมาตรฐานในวงการ

ทำไมไม่ใช้ i18next?

ตอนแรกที่ผมคิดเรื่องเพิ่มการแปลภาษา ผมก็พิจารณาตัวมาตรฐานในวงการอย่าง i18next และ react-i18next ก่อน

แต่สุดท้ายผมตัดสินใจเลือก optimize ให้ AI ดูแลรักษาได้ง่าย แทน. i18next นั้นทรงพลังก็จริง แต่ความหลากหลายของ API อาจทำให้ LLM hallucinate หรือเขียนโค้ดไม่สอดคล้องกัน. การจำกัดไลบรารีให้เหลือแค่ t() กับ interpolate() แบบเรียบง่าย ทำให้ผมมั่นใจได้ว่า agent กว่า 10 ตัวที่ทำงานพร้อมกันสามารถเขียนโค้ดที่ type-safe 100% ได้โดยแทบไม่ต้องมีมนุษย์เข้าไปยุ่งเลย

ผมยังไม่อยากผูกตัวเองเข้ากับ ecosystem ใหญ่ๆ ที่อาจมี breaking change ตามมาทีหลังด้วย. หลังจากเคยเจ็บปวดกับการ migrate โหดๆ อย่าง React Router v5 และ MUI v4 → v5 มาแล้ว ผมรู้ดีว่าการที่ backwards-compatibility พังไวๆ แบบนี้เป็นเรื่องปกติเกินไปในโลกของ JavaScript. ต้นทุนของการเพิ่มฟีเจอร์ pluralization ทีหลังนั้นถูกกว่าต้นทุนของการ migrate โค้ด 139,000 บรรทัดด้วยมือตอนนี้เยอะ

ผมอยากได้อะไรที่เรียบง่ายสุดๆ น้ำหนักเบามาก และเหมาะกับความต้องการของทีมผมเป๊ะๆ

ผมเลยเขียนเองเลย

ผมสร้าง subset ขนาด 3 KB ที่ออกแบบมาเฉพาะเพื่อให้ AI สามารถ refactor ได้อย่างอัตโนมัติและแม่นยำสูง. นี่ทำให้ผมในฐานะวิศวกรคนเดียวสามารถทำงานที่ปกติต้องใช้ทีม 5 คน 3 สัปดาห์ ให้เสร็จได้ใน 3 วัน

การ Implement แบบกำหนดเอง

ผมสร้างไลบรารี i18n ขนาดมินิมอลที่มีขนาดประมาณ 3 KB เมื่อ gzip แล้ว. มันมีฟังก์ชันหลัก 2 ตัว: getTranslation() สำหรับ context ที่ไม่ใช่ React และ hook useTranslation() สำหรับ component

ทั้งสองตัวจะคืนค่า t() สำหรับการแทนที่ string แบบง่ายๆ และ interpolate() สำหรับเวลาที่ผมต้องใส่ React component เข้าไปใน string การแปล (เช่น link หรือ icon). ทั้งสองฟังก์ชันรองรับการแทนที่ตัวแปร เช่น "Hello {{thing}}", {thing: 'World'}

Key ใช้รูปแบบ "slash-dot" (slash สำหรับ path ของไฟล์ localization, dot สำหรับ object ซ้อนกันในไฟล์). เพื่อให้แน่ใจว่าไม่ซ้ำกัน key ของการแปลในไฟล์หนึ่งห้ามมีเครื่องหมาย forward-slash

นี่คือฟังก์ชัน 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-load ไฟล์การแปล เพื่อไม่ต้องส่งภาษาทั้ง 20 ภาษาให้ผู้ใช้ทุกคน
  • Code-split การแปลตาม "namespace" (เช่น common, misc, games/{gameId})
  • locale "debug" ที่แสดง key ดิบๆ ออกมา ทำให้ผมตรวจสอบได้ว่าทุกอย่างเชื่อมต่อถูกต้องไหม

เพื่อให้ระบบดูแลรักษาง่าย ผมยังเพิ่มเอกสารแบบครอบคลุมไว้ใน shared/src/i18n/README.md ครอบคลุมตั้งแต่โครงสร้างไฟล์ไปจนถึงตัวอย่างการใช้งานทั้งฝั่ง client และ server. เนื่องจากผมไม่ได้ใช้ไลบรารีมาตรฐาน การมีเอกสารอ้างอิงนี้สำคัญมากสำหรับการ onboard สมาชิกทีมใหม่ (หรือเตือนตัวเองในอนาคตหรือ LLM ว่ามันทำงานยังไง)

ดูตัวเลขกัน

เพื่อให้เห็นภาพขนาดของการอัปเดตครั้งนี้ นี่คือสิ่งที่เปลี่ยนไปในโค้ดเบส:

  • รองรับ 20 ภาษา (พร้อม locale debug สำหรับ dev)
  • สร้างไฟล์ locale 360 ไฟล์
  • โค้ดการแปล 139,031 บรรทัด
  • เพิ่มการเรียก t() 3,938 ครั้งทั่วฝั่ง client
  • แก้ไขไฟล์ source 728 ไฟล์
  • ไฟล์ source ภาษาอังกฤษ 18 ไฟล์ที่เป็นแหล่งความจริง (16 เกม + common + misc)

ออเคสเตรตด้วย Agent

ถ้าทำด้วยมือ คงต้องใช้เวลาเป็นเดือนๆ กับงานน่าเบื่อแบบกลไก. แทนที่จะทำแบบนั้น ผมออเคสเตรต Cursor agent กว่าโหลพร้อมกัน ให้พวกมันยกของหนัก

ผมเริ่มจากการแบ่งโค้ดเบสออกเป็น "section" ตามโฟลเดอร์. แต่ละเกมบน Foony มีโฟลเดอร์ของตัวเองและ namespace การแปลของตัวเอง. นี่ทำให้ขนาดการโหลดครั้งแรกเล็ก เพราะคุณโหลดแค่การแปลของเกมที่กำลังเล่นอยู่

ผมรัน Cursor agent หลายตัวพร้อมกัน. ผมมอบหมายแต่ละ agent ให้ section เฉพาะ เช่น "แปลงเกม Chess ให้ใช้การแปลภาษา" แล้วมันก็จะไล่ไฟล์ทีละไฟล์ หา string ที่แสดงให้ผู้ใช้เห็น แล้วแทนที่ด้วย t('games/chess/some.key')

จากนั้น agent จะเพิ่ม key ลงในไฟล์ locale ภาษาอังกฤษที่เหมาะสม พร้อมคอมเมนต์ JSDoc อธิบายว่า "อะไร" และ "อยู่ที่ไหน" ของ string นั้น. context นี้สำคัญตอนสร้างคำแปลสำหรับภาษาอื่น เพราะมันช่วยให้ LLM เข้าใจว่า "Save" หมายถึง "บันทึกการตั้งค่าเกม" หรือ "บันทึกภาพวาด Draw & Guess ของคุณ"

ควบคุมคุณภาพ

ผมรีวิวโค้ดทั้งหมดที่ถูกสร้างขึ้นอย่างรวดเร็ว. agent ทำได้ดีอย่างน่าประหลาดใจ แต่บางครั้งก็พลาดบ้าง เช่น วาง hook useTranslation ไว้หลังคำสั่ง return ก่อนกำหนด

การแปลแบบ strongly-typed ช่วยได้มหาศาล. มันทำให้แน่ใจว่าการแปลทั้งหมดของแต่ละ locale มี key ที่ถูกต้องครบถ้วน (และไม่มีตัวที่ผิด). มันยังทำให้แน่ใจว่าการเรียก t() และ interpolate() ใช้ string การแปลของจริงที่มีอยู่

ระบบ type จะดึง key การแปลที่เป็นไปได้ทั้งหมดจากไฟล์ source ภาษาอังกฤษ:

/**
 * 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 ได้สมบูรณ์แบบ และการพิมพ์ผิดใน key การแปลจะถูกจับได้ตอน compile time. agent ไม่สามารถพลาดแบบ t('games/ches/name') ได้ เพราะ TypeScript จะแจ้งเตือนทันที

การ Localize

พอแปลงภาษาอังกฤษเสร็จแล้ว ผมก็แบ่งงาน locale ที่เหลือออก. ผมให้แต่ละ agent รับผิดชอบการแปลงไฟล์ locale ภาษาอังกฤษ 1 ไฟล์เป็นภาษาที่กำหนด

ตัวอย่างเช่น ผมให้ prompt agent แบบนี้:

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 สร้างสคริปต์ที่ป้อนแต่ละไฟล์เข้า LLM แล้วให้มันสร้างของให้ แต่ผมอยากประหยัดค่า LLM นิดหน่อย. การใช้สคริปต์เพื่ออัปเดตเฉพาะคำแปลที่ขาดอยู่จะเป็นวิธีที่ดีกว่า และผมคงใช้ solution คล้ายๆ กันในอนาคต. ผมอยากติดตามว่า string ไหนต้องอัปเดต / แปล แต่ก็อยากให้มันเรียบง่าย. ผมอาจจะย้ายงานแปลไปที่ database หรืออะไรสักอย่าง

ผมยังเพิ่ม locale "debug" ที่ใช้ได้เฉพาะใน development. มันให้ผมดู string ที่ถูกแทนที่ทั้งหมดเพื่อตรวจสอบว่าทุกอย่างทำงาน (แถมผมว่ามันเท่ดี). เมื่อใช้ locale debug t() จะคืนค่า key ที่ห่อด้วยวงเล็บ:

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

ดังนั้นแทนที่จะเห็น "Welcome to Foony!" คุณจะเห็น ⟦welcome⟧ ทำให้สังเกตคำแปลที่ขาดหายไปได้ง่าย

สุดท้าย agent อีกตัว implement routing /{locale}/** เพื่อให้สิ่งต่างๆ อย่าง /ja/games/chess route ไปยังภาษาที่ถูกต้อง (ในกรณีนี้คือภาษาญี่ปุ่น)

แปลบล็อก

แปล string ของ UI ก็เรื่องหนึ่ง แต่บล็อกล่ะ? ผมไม่อยากปลุก agent ขึ้นมาจัดการเพิ่มอีกเพื่อแปลบล็อกทั้งหมด

ผมแก้ปัญหานี้โดยให้ agent สร้างสคริปต์ (scripts/src/generateBlogTranslations.ts) ที่ทำให้ทั้งกระบวนการเป็นอัตโนมัติ

มันทำงานยังไง:

  1. มันสแกน directory client/src/posts/en เพื่อหาไฟล์ MDX ภาษาอังกฤษ
  2. มันตรวจสอบคำแปลที่ขาดในโฟลเดอร์ locale อื่น (เช่น posts/ja, posts/es)
  3. ถ้าคำแปลขาดหายไป มันจะอ่านเนื้อหาภาษาอังกฤษและป้อนเข้า Gemini 3 Pro Preview พร้อม prompt เฉพาะเพื่อแปลเนื้อหาโดยรักษารูปแบบ Markdown ไว้
  4. มันบันทึกไฟล์ใหม่ไว้ที่ตำแหน่งที่ถูกต้อง

บน frontend ผมใช้ import.meta.glob เพื่อ import ไฟล์ MDX เหล่านี้ทั้งหมดแบบ dynamic. component PostPage ของผมจะตรวจสอบ locale ปัจจุบันของผู้ใช้และ lazy-load ไฟล์ MDX ที่ถูกต้อง. ถ้าคำแปลขาดหายไป (เพราะผมยังไม่ได้รันสคริปต์) มันจะ fallback กลับไปเป็นภาษาอังกฤษอย่างนุ่มนวล

วันที่ 4: สร้างคำแปลอัตโนมัติ

ผมรู้ว่า solution เดิมมัน scale ไม่ได้. ดังนั้น เมื่อ i18n พร้อมแล้ว ก็ถึงเวลาทำให้มันแกร่งขึ้นนิดหน่อยด้วยแนวทางที่ขับเคลื่อนด้วย database

สรุปสั้นๆ: เมื่อข้อความภาษาอังกฤษหรือคอมเมนต์ JSDoc เปลี่ยน คำแปลก็ต้องถูกสร้างใหม่. การติดตามด้วยมือว่าอะไรต้องอัปเดตจะมีโอกาสผิดพลาดสูงและเสียเวลานักพัฒนา

ผมเลยสร้าง solution ที่วางแผนไว้ตั้งแต่แรก: ระบบสร้างคำแปลที่ใช้ PostgreSQL เป็นฐาน

Schema ของ Database

ผมเพิ่มตาราง translations ใน PostgreSQL database พร้อมโครงสร้างต่อไปนี้:

  • key: key การแปลในรูปแบบ "slash-dot" (เช่น "games/yacht/nested.name", "config.timeLimit.label")
  • en_value: ค่า source ภาษาอังกฤษ
  • target_locale: รหัส locale ปลายทาง (เช่น "es", "fr", "zh")
  • target_value: ค่าที่แปลแล้ว
  • context: field JSONB ที่มี JSDoc สำหรับ key นี้และ key บรรพบุรุษทั้งหมด
  • created_at และ updated_at: timestamp สำหรับการติดตาม

unique index อยู่บน (key, target_locale, en_value, context). นี่สำคัญมาก: การใส่ context ไว้ใน unique constraint ทำให้เราสามารถตรวจจับเมื่อคอมเมนต์ JSDoc เปลี่ยนได้อัตโนมัติ และสร้างคำแปลใหม่. คำแปลเก่าจะถูกเก็บไว้เพื่ออ้างอิงทางประวัติศาสตร์

สคริปต์สร้างคำแปล

ผมสร้าง scripts/src/generateLocalizations.ts ที่ทำให้ workflow การแปลทั้งหมดเป็นอัตโนมัติ:

  1. ดึง key ภาษาอังกฤษ: ใช้ AST parsing (ts-morph) เพื่อดึง key การแปลทั้งหมดจากไฟล์ shared/src/i18n/locales/en/** โดยประมวลผลเฉพาะ default export
  2. ดึง context JSDoc: parse คอมเมนต์ JSDoc สำหรับแต่ละ key และ key บรรพบุรุษทั้งหมด (object พ่อแม่) เพื่อให้ context ที่ลึกซึ้ง
  3. Query database: ตรวจสอบคำแปลที่มีอยู่ใน PostgreSQL โดยจับคู่ตาม key, target_locale, en_value และ context ถ้าตัวใดเปลี่ยน คำแปลจะถูกสร้างใหม่
  4. ระบุ key ที่ขาด/เปลี่ยน: หา key ที่ต้องการการแปล หรือมีค่าภาษาอังกฤษ/คอมเมนต์เปลี่ยน
  5. รวมคำแปลเป็นชุด: จัดกลุ่มตาม locale และ namespace prefix เพื่อให้การเรียก LLM มีประสิทธิภาพมากขึ้น (และทำให้แปลเร็วขึ้นด้วย). แต่ถ้าชุดใหญ่เกินไป คุณภาพการแปลจะแย่ลง
  6. สร้างคำแปล: ใช้ GPT 5.1 พร้อม context ที่ครอบคลุม (JSDoc, ภาษา+ภูมิภาค, โทน, glossary, ตัวอย่าง). ผมเคยอ่านมาว่า 5.1 เขียนดีกว่า 5.2 (ไม่เลี่ยน) แต่ยังไม่ได้ยืนยัน
  7. ตรวจสอบ QA: ตรวจสอบว่า placeholder ถูกรักษาไว้ เช่น {{name}}, ความสมบูรณ์ของ key, รูปแบบ JSON
  8. เก็บใน database: บันทึกคำแปลพร้อม context เต็ม (JSDoc + JSDoc บรรพบุรุษ)
  9. สร้างไฟล์ locale: อ่านจาก database และเขียนไฟล์ locale TypeScript ที่จัดรูปแบบเรียบร้อยพร้อม type RecursivePartial

ประโยชน์หลักๆ

แนวทางนี้ให้การปรับปรุง DevEx หลายอย่าง:

  • สร้างใหม่อัตโนมัติ: เมื่อข้อความภาษาอังกฤษหรือคอมเมนต์ JSDoc เปลี่ยน คำแปลจะถูกสร้างใหม่อัตโนมัติ. ดังนั้นถ้ามีคนบอกว่าคำแปลแย่ มันก็ง่ายมากที่จะสร้างคำแปลใหม่โดยใส่ context เพิ่มเป็นคอมเมนต์
  • Context ที่หลากหลาย: คอมเมนต์ JSDoc ให้ context ของการแปล (เช่น "ข้อความ error ที่แสดงให้ผู้เล่น สูงสุด 15 ตัวอักษร") ช่วยให้ LLM ผลิตคำแปลที่แม่นยำขึ้น
  • Context จากบรรพบุรุษ: JSDoc ของ object พ่อแม่ให้ context ของ namespace (เช่น "Achievement สำหรับการอยู่ในเกมที่ไข่ทุกใบถูกทำลาย") ทำให้ชัดเจนขึ้นอีกนิด
  • ติดตามประวัติ: คำแปลเก่าถูกเก็บไว้ใน database. ไม่กินที่เท่าไหร่ ผมเลยไม่เห็นเหตุผลที่จะลบตอนนี้ และเท่ดีที่ได้เห็นประวัติ

รายละเอียดทางเทคนิค

การ implement ใช้เทคนิคหลายอย่างเพื่อให้น่าเชื่อถือและมีประสิทธิภาพ:

  • การดึงข้อมูลที่อิงกับ AST เพื่อให้แน่ใจว่าได้คอมเมนต์ที่ถูกต้อง
  • การประมวลผลแบบขนานโดยใช้ Semaphore สำหรับการแปลเป็นชุดพร้อมกัน
  • logic retry แบบ exponential backoff สำหรับ API ที่ล้มเหลว. การเรียก LLM ขึ้นชื่อเรื่องไม่เสถียร

สามารถรันสคริปต์ได้ด้วยคำสั่ง npm run generate-localizations จาก directory scripts. มันจะเชื่อมต่อกับ PostgreSQL และประมวลผลคำแปลที่ขาดหรือเปลี่ยนแปลงทั้งหมดสำหรับทุก locale ที่รองรับเมื่อรัน

บทสรุป

ตอนนี้ ผมมีเว็บที่ทำงานได้เต็มรูปแบบและถูกแปลเป็นทั้ง 20 locale แล้ว!

3 วันนี้บ้าคลั่งมาก แต่ผลลัพธ์ที่ได้คือเว็บที่ถูก localize เต็มที่ และให้ความรู้สึก (แทบจะ) เหมือนเขียนมาเองสำหรับผู้ใช้ทั่วโลก. ด้วยการสร้างไลบรารีน้ำหนักเบาแบบกำหนดเอง และใช้ AI agent ในงาน refactor ที่น่าเบื่อ ผมทำสิ่งที่เป็นไปไม่ได้เลยเมื่อปีก่อนได้: i18n เต็มรูปแบบใน 3 วันสำหรับเว็บไซต์ที่ซับซ้อน โดยวิศวกรคนเดียว. อนาคตของการเขียนโปรแกรมไม่ใช่การเขียนโค้ดให้เร็ว. แต่คือการออเคสเตรต AI agent และมีความเชี่ยวชาญเชิงลึกในโดเมนเพื่อตรวจสอบผลลัพธ์ของพวกมัน

8 Ball Pool online multiplayer billiards icon