Skip to content

บทที่ 15 — Internationalization (i18n) + Localization

🟡 ก่อนอ่าน: ควรผ่านบท 1 (Components + Templates), บท 2 (Signals + State), บท 3 (Services + DI), บท 4 (Routing) และบท 9 (SSR) มาก่อน — โดยเฉพาะ SSR เพราะส่วน Transloco setup ต้องระวัง localStorage (พื้นที่เก็บข้อมูลในเบราว์เซอร์) / navigator (object ข้อมูลเบราว์เซอร์) ที่รันบน server ไม่ได้

← บทที่ 14: Material + CDK | สารบัญ | กลับสารบัญหลัก →

หลังจบบท คุณจะ:

  • แยก i18n vs l10n vs g11n
  • ใช้ @angular/localize (built-in compile-time)
  • ใช้ Transloco / ngx-translate (runtime switch)
  • จัดการ pluralization, gender, date/number format ต่อ locale
  • รองรับ RTL (Right-to-Left) สำหรับ Arabic/Hebrew
  • Strategy: ต้องเลือก built-in หรือ library ตอนไหน

ใช้เวลา 2-3 ชั่วโมง


1. ทำไม i18n สำคัญ

การรองรับหลายภาษาไม่ใช่แค่ "ของแถม" — ผู้ใช้อินเทอร์เน็ตส่วนใหญ่ไม่ได้ใช้ภาษาอังกฤษเป็นหลัก และมักเลือกซื้อจากเว็บที่เป็นภาษาตัวเอง ก่อนเริ่มต้องเข้าใจศัพท์ที่สับสนกันบ่อย (i18n/l10n/g11n) และรู้ว่าการแปลภาษาที่แท้จริงครอบคลุมมากกว่าแค่คำ — รวมถึงรูปแบบวันที่ ตัวเลข สกุลเงิน และทิศทางการอ่าน:

text
🌏 หลักการ:
- > 75% ของผู้ใช้อินเทอร์เน็ตไม่ใช้ภาษาอังกฤษเป็นหลัก
- 56% ผู้ซื้อใน e-commerce ต้องการเว็บภาษาตัวเอง
- Enterprise: บังคับ EU multilingual

📌 ศัพท์:
- i18n   — Internationalization (เตรียม code ให้รองรับหลายภาษา)
- l10n   — Localization (แปลภาษา + ปรับ format)
- g11n   — Globalization (เตรียม + localize + cultural)
- t9n    — Translation (แค่แปล)

l10n ครอบคลุมอะไรบ้าง (ไม่ใช่แค่แปลคำ)

text
✅ Text strings (UI labels, messages) — ข้อความบนหน้าจอ
✅ Plural rules (1 item / 2 items / ...) — กฎพหูพจน์
✅ Gender (he/she/they) — การกำหนดเพศ
✅ Date format (12/25/2026 vs 25/12/2026 vs 2026年12月25日) — รูปแบบวันที่
✅ Number format (1,000.50 vs 1.000,50 vs ١٬٠٠٠٫٥٠) — รูปแบบตัวเลข
✅ Currency ($100 vs ¥100 vs ₿100) — สกุลเงิน
✅ Time zone — เขตเวลา
✅ Calendar (Gregorian vs Buddhist vs Hijri) — ระบบปฏิทิน
✅ Address format — รูปแบบที่อยู่
✅ Phone format — รูปแบบเบอร์โทร
✅ RTL (Arabic, Hebrew, Persian, Urdu) — ภาษาที่อ่านจากขวาไปซ้าย
✅ Honorifics (Japanese: -san, -sama) — คำนำหน้านาม
✅ Cultural icons/colors (red = lucky CN / danger US) — ไอคอน/สีตามวัฒนธรรม

2. 2 แนวทางใน Angular

text
A. @angular/localize (built-in, compile-time)
   - 1 build = 1 locale (build 1 ครั้ง ได้ 1 ภาษา)
   - ใช้ Angular i18n marker (i18n attribute)
   - File: XLIFF / XMB (รูปแบบไฟล์มาตรฐานสำหรับเก็บคำแปล ที่นักแปลรับไปใช้งาน)
   - SEO-friendly
   - Performance: pre-rendered HTML, no runtime overhead (ไม่มีค่าใช้จ่ายตอนรัน)
   ❌ User switch language = page reload + load new bundle (ผู้ใช้เปลี่ยนภาษา = โหลดหน้าใหม่)
   
B. ngx-translate / Transloco (runtime, library)
   - Single bundle for all locales (bundle เดียวรองรับทุกภาษา)
   - JSON files load at runtime (โหลดไฟล์แปลตอนรัน)
   - User switch instantly (สลับภาษาได้ทันที)
   - More flexible (lazy load namespace) (ยืดหยุ่นกว่า — โหลด namespace แบบ lazy)
   ❌ Larger bundle (translations in JS) (bundle ใหญ่กว่า)
   ❌ Slight runtime cost (มีค่าใช้จ่ายตอนรันเล็กน้อย)

เลือกแบบไหน?

text
Built-in @angular/localize:
✅ Public-facing app (SEO ต้องการ)
✅ Locale ไม่บ่อยเปลี่ยน
✅ Few locales (2-5) — มีไม่กี่ภาษา
✅ Heavy text content (blog, docs) — เนื้อหาข้อความเยอะ (บล็อก, เอกสาร)

Transloco / ngx-translate:
✅ Internal app (logged-in user) — แอปภายใน (ผู้ใช้ที่ login แล้ว)
✅ User switch ภาษาบ่อย
✅ Many locales (10+) — มีหลายภาษา (10 ขึ้นไป)
✅ Lazy-load namespaces — โหลดแยกตาม feature
✅ Use shared translations across micro-frontends — แชร์คำแปลข้าม micro-frontend

📌 ณ ต้นปี 2026 — Transloco ดูมี active development ต่อเนื่องกว่า, รองรับ standalone/signals API ได้ดี และมี Angular-specific features (เช่น scope, SSR support) — ส่วน ngx-translate ยังมีชุมชนขนาดใหญ่และพบบ่อยใน legacy codebase; นี่เป็นภาพ ณ ช่วงเวลาหนึ่ง ควรตรวจสอบสถานะ maintenance ล่าสุดของทั้งสอง library ก่อนตัดสินใจใช้ในโปรเจกต์ใหม่


3. @angular/localize (Built-in)

Setup

bash
ng add @angular/localize

→ Adds @angular/localize package + polyfill
→ (ng add = ติดตั้ง package + ตั้งค่าให้อัตโนมัติ ต่างจาก npm install ที่แค่ดาวน์โหลด package อย่างเดียว)

Mark Text for Translation

i18n attribute คือสัญญาณที่คุณติดบนแท็ก HTML เพื่อบอก Angular ว่า "ข้อความนี้ต้องแปล" เหมือนติดสติกเกอร์สีเหลืองบนเอกสารที่ต้องส่งนักแปล — Angular CLI จะวิ่งไปเก็บข้อความที่มีสติกเกอร์นี้ทั้งหมดออกมาไว้ในไฟล์เดียวเพื่อส่งให้นักแปล

ส่วนที่สำคัญมากคือ @@homeHero ซึ่งเป็น ID คงที่ที่คุณตั้งให้แต่ละข้อความ ถ้าไม่ตั้ง Angular จะ generate ID อัตโนมัติจาก content ของข้อความ ปัญหาคือ ถ้าคุณแก้ข้อความจาก "Welcome to our site" เป็น "Welcome!" ID จะเปลี่ยน — และนักแปลต้องแปลข้อความนั้นใหม่ทั้งหมดทุกภาษา เหมือนเปลี่ยนชื่อไฟล์แล้วต้อง re-link ทุกที่ที่อ้างถึง:

html
<!-- ❌ ไม่มี ID — Angular auto-generate จาก content, เปลี่ยนทุกครั้งที่แก้ข้อความ -->
<p i18n>Hello</p>

<!-- ✅ มี ID คงที่ — แก้ข้อความได้โดยไม่ทำให้งานแปลหาย -->
<p i18n="@@greet">Hello</p>
html
<!-- Simple -->
<p i18n>Hello, World!</p>

<!-- With meaning + description (syntax: meaning|description@@id) -->
<p i18n="Home page hero message@@homeHero">Welcome to our site</p>

<!-- Plural -->
<p i18n>{count, plural, =0 {No items} =1 {1 item} other {{{count}} items}}</p>

<!-- Select (gender) -->
<p i18n>{gender, select, male {He} female {She} other {They}}</p>

<!-- Attribute -->
<img src="logo.svg" i18n-title title="My logo">
<input i18n-placeholder placeholder="Enter email">

<!-- Multiple attributes -->
<button 
    i18n="@@saveBtn" 
    i18n-aria-label="@@saveBtnAria"
    aria-label="Save changes"
>
    Save
</button>

Mark in TypeScript

$localize ทำงานเหมือน template literal ธรรมดาที่คุณรู้จัก เช่น `Hello, ${name}!` แต่มีตัว $localize นำหน้า — มันคือ "ป้ายสีไฮไลต์" ที่ Angular CLI อ่านตอน build แล้วดึงข้อความออกไปใส่ไฟล์แปล เหมือนช่างแปลที่ใช้ highlighter mark ประโยคในต้นฉบับก่อนแปล ตอนรัน Angular จะใส่ค่าที่แปลแล้วกลับเข้ามาแทนที่

polyfill โดยทั่วไปคือโค้ดเสริมที่เติม feature ที่ environment (เบราว์เซอร์หรือ runtime) ไม่มีให้ในตัว ให้ใช้งานได้เหมือนมีมาแต่แรก — ในบริบทนี้ polyfill คือโค้ดที่ ng add @angular/localize เพิ่มให้อัตโนมัติ เพื่อให้ $localize มีอยู่ใน global scope ก่อน build เราจึงไม่ต้อง import เอง แค่เรียกใช้ได้เลย

📖 $localize คืออะไร — เป็น tagged template literal ของ Angular (เหมือน html`...` ) ที่บอกตัว build ให้ "ดึงข้อความนี้ออกไปแปล" Angular CLI จะอ่านโค้ดตอน build แล้ว generate ไฟล์ XLIFF ให้นักแปล — ส่วน :@@errorMsg: คือการตั้ง id ตายตัว (ไม่ให้ id เปลี่ยนเมื่อข้อความ source เปลี่ยน)

⚠️ build-time vs runtime (สำคัญมาก):

  • @angular/localize = build-time$localize ถูก "ฝัง" ค่าแปลเข้า bundle ตอน ng build --localize → 1 build ต่อ 1 ภาษา, สลับภาษา = โหลด bundle ใหม่
  • Transloco/ngx-translate = runtime — โหลดไฟล์แปลตอน runtime, สลับภาษาได้ทันทีโดยไม่โหลดหน้าใหม่
  • ทั้งสองมีจุดอ่อนของตัวเอง — เลือกตามตาราง section 2
typescript
// ⚠️ ไม่ต้อง import $localize เอง — ng add @angular/localize เพิ่ม polyfill ให้อัตโนมัติ
// $localize เป็น global tagged template literal ที่ Angular inject ผ่าน polyfill
// (ถ้าต้องการ type support ให้ใส่ /// <reference types="@angular/localize" /> ใน tsconfig)
import { Component, signal, computed } from '@angular/core';

@Component({...})
export class GreetingComponent {
    name = signal('World');
    
    // `$localize` tag = mark string นี้ให้ Angular extract ออกไปแปล
    greeting = computed(() => $localize`Hello, ${this.name()}!`);
    
    showError() {
        // `:@@errorMsg:` = ตั้ง custom id ให้ trans-unit (stable id)
        alert($localize`:@@errorMsg:Something went wrong`);
    }
}

Extract Messages

bash
ng extract-i18n --output-path=src/locale

→ Generates src/locale/messages.xlf (XLIFF 1.2 default)

XLIFF (XML Localization Interchange File Format) = ไฟล์ XML มาตรฐานที่เก็บข้อความต้นฉบับ + คำแปล
Flow: นักพัฒนาสร้าง → ส่งให้นักแปล → นักแปลเติมคำแปล → นักพัฒนาใช้ build

xml
<!-- messages.xlf -->
<xliff version="1.2">
  <file source-language="en" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="homeHero" datatype="html">
        <source>Welcome to our site</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home.component.html</context>
          <context context-type="linenumber">3</context>
        </context-group>
        <note priority="1" from="description">Home page hero message</note>
      </trans-unit>
    </body>
  </file>
</xliff>

Translate

ส่ง XLIFF ให้นักแปล → ได้คืน messages.th.xlf:

xml
<trans-unit id="homeHero">
    <source>Welcome to our site</source>
    <target>ยินดีต้อนรับสู่เว็บไซต์ของเรา</target>
</trans-unit>

Configure Locales

json
// angular.json — ไฟล์ config หลักของโปรเจกต์ Angular อยู่ที่ root ของโปรเจกต์
// เพิ่มส่วน "i18n" ใต้ "projects" → ชื่อโปรเจกต์ของคุณ
{
    "projects": {
        "my-app": {
            "i18n": {
                "sourceLocale": "en",
                "locales": {
                    "th": {
                        "translation": "src/locale/messages.th.xlf",
                        "baseHref": "/th/"
                    },
                    "ja": {
                        "translation": "src/locale/messages.ja.xlf",
                        "baseHref": "/ja/"
                    }
                }
            },
            "architect": {
                "build": {
                    "configurations": {
                        "production": {
                            "localize": true
                        },
                        "th": {
                            "localize": ["th"]
                        },
                        "ja": {
                            "localize": ["ja"]
                        }
                    }
                }
            }
        }
    }
}

Build Per Locale

bash
# All locales
ng build --localize

# Single locale
ng build --configuration=th

# Output:
# dist/my-app/
#   en/   ← English
#   th/   ← Thai
#   ja/   ← Japanese

Serve in Dev

bash
ng serve --configuration=th

Deploy Per Locale

text
Option A: Sub-path (recommended)
example.com/en/
example.com/th/
example.com/ja/

Option B: Subdomain
en.example.com
th.example.com
ja.example.com

Option C: Country domain
example.com (US)
example.co.th (Thailand)
nginx
# Detect Accept-Language + redirect
location = / {
    if ($http_accept_language ~ "^th") {
        return 302 /th/;
    }
    if ($http_accept_language ~ "^ja") {
        return 302 /ja/;
    }
    return 302 /en/;
}

Built-in Pipes (Locale-aware)

html
<!-- Use locale of build -->
<p>{{ today | date }}</p>                    <!-- Aug 5, 2026 (en) / 5 ส.ค. 2026 (th) -->
<!-- ⚠️ DatePipe + locale 'th' ยังออกปี ค.ศ. ไม่ใช่ พ.ศ. — ต้องใช้ Intl API ด้วย 'th-TH-u-ca-buddhist' ถ้าต้องการ พ.ศ. -->
<p>{{ price | currency:'USD' }}</p>          <!-- $100.00 (en) / US$100.00 (th) -->
<p>{{ ratio | percent:'1.0-2' }}</p>          <!-- 50% -->
<p>{{ count | number:'1.0-2' }}</p>           <!-- 1,234.50 (en) / 1.234,50 (de) -->

→ Pipes ใช้ LOCALE_ID provider — ตั้งโดย --localize


4. Transloco (Runtime Switching)

ถ้าต้องการให้ผู้ใช้ "สลับภาษาได้ทันที" โดยไม่ต้องโหลดหน้าใหม่ ให้ใช้ไลบรารีแบบ runtime อย่าง Transloco — มันเก็บคำแปลไว้ในไฟล์ JSON ที่โหลดตอนทำงาน ทำให้เปลี่ยนภาษากลางคันได้ลื่นไหล มาดูตั้งแต่ติดตั้ง จัดไฟล์แปล ไปจนถึงใช้ในเทมเพลต:

bash
ng add @jsverse/transloco

→ Prompts:

text
? Which languages do you need? en, th, ja
? Use SSR? No
? Use Angular i18n built-in fallback? No

Setup

📌 ลำดับการทำ (สำคัญ): โค้ดนี้ import TranslocoHttpLoader จากไฟล์ transloco-loader.ts ซึ่งเรายังไม่ได้สร้าง — ให้ข้ามไปสร้างไฟล์นั้นก่อนที่หัวข้อ "Loader" ด้านล่าง (จริง ๆ ng add @jsverse/transloco มักสร้างให้อัตโนมัติอยู่แล้ว) แล้วค่อยกลับมาตั้งค่าส่วนนี้ ไม่งั้นจะเจอ error ว่าหาไฟล์ไม่เจอ

typescript
// app.config.ts
// ⚠️ Step 1: สร้าง transloco-loader.ts ที่หัวข้อ "Loader" ด้านล่างก่อน
// ⚠️ Step 2: กลับมาตั้งค่าส่วนนี้ (ไม่งั้น import บรรทัดล่างจะ error เพราะยังไม่มีไฟล์)
import { provideTransloco } from '@jsverse/transloco';
import { TranslocoHttpLoader } from './transloco-loader';

export const appConfig: ApplicationConfig = {
    providers: [
        provideTransloco({
            config: {
                availableLangs: ['en', 'th', 'ja'],
                defaultLang: 'en',
                fallbackLang: 'en',
                reRenderOnLangChange: true,
                prodMode: !isDevMode()
            },
            loader: TranslocoHttpLoader
        })
    ]
};

Translation Files

text
assets/i18n/
├── en.json
├── th.json
└── ja.json
json
// assets/i18n/en.json
{
    "welcome": "Welcome",
    "greeting": "Hello, {{name}}!",
    "items": {
        "zero": "No items",
        "one": "1 item",
        "other": "{{count}} items"
    },
    "header": {
        "title": "My App",
        "menu": {
            "home": "Home",
            "about": "About"
        }
    }
}
json
// assets/i18n/th.json
{
    "welcome": "ยินดีต้อนรับ",
    "greeting": "สวัสดี {{name}}!",
    "items": {
        "zero": "ไม่มีรายการ",
        "one": "1 รายการ",
        "other": "{{count}} รายการ"
    },
    "header": {
        "title": "แอปของฉัน",
        "menu": {
            "home": "หน้าแรก",
            "about": "เกี่ยวกับ"
        }
    }
}

Use in Template

typescript
// Transloco 7+ รองรับ granular imports — แนะนำให้ใช้ใน standalone components เพื่อ tree-shaking ที่ดีกว่า:
// import { TranslocoDirective, TranslocoPipe } from '@jsverse/transloco';
// imports: [TranslocoDirective, TranslocoPipe, RouterLink]
// ตัวอย่างด้านล่างใช้ TranslocoModule (import ทั้ง module) เพื่อความกระชับ
import { TranslocoModule } from '@jsverse/transloco';
import { RouterLink } from '@angular/router';

@Component({
    standalone: true,
    imports: [TranslocoModule, RouterLink],
    template: `
        <!-- Pipe (recommended) -->
        <h1>{{ 'welcome' | transloco }}</h1>
        
        <!-- Pipe with params -->
        <p>{{ 'greeting' | transloco:{ name: 'Anna' } }}</p>
        
        <!-- Nested key -->
        <h2>{{ 'header.title' | transloco }}</h2>
        
        <!-- Structural directive ของ Transloco (ไม่ใช่ Angular built-in) จึงยังใช้ * prefix
             ข้อดี: subscribe ครั้งเดียว ดีกว่าใช้ pipe หลายตัว (ลด re-render) -->
        <ng-container *transloco="let t">
            <h1>{{ t('welcome') }}</h1>
            <p>{{ t('greeting', { name: user.name }) }}</p>
            <nav>
                <a [routerLink]="'/'">{{ t('header.menu.home') }}</a>
                <a [routerLink]="'/about'">{{ t('header.menu.about') }}</a>
            </nav>
        </ng-container>
        
        <!-- With scope (lazy load namespace) — scope 'admin' จะ prefix key ให้อัตโนมัติ -->
        <ng-container *transloco="let t; scope: 'admin'">
            <h1>{{ t('title') }}</h1>  <!-- ไม่ต้องเขียน 'admin.title' — scope prefix ให้แล้ว -->
        </ng-container>
    `
})
export class HomeComponent {
    user = { name: 'Anna' };
}

Use in TypeScript

typescript
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslocoService } from '@jsverse/transloco';

@Component({...})
export class MyComponent {
    private translocoService = inject(TranslocoService);
    // private notify = inject(NotificationService);  // inject notification service ของคุณเอง
    
    showError() {
        const msg = this.translocoService.translate('errors.required');
        console.error(msg);  // แทน this.notify.error(msg) — inject NotificationService เองตามโปรเจกต์
    }
    
    changeLanguage(lang: string) {
        this.translocoService.setActiveLang(lang);
    }
    
    activeLang = toSignal(
        this.translocoService.langChanges$,
        { initialValue: this.translocoService.getActiveLang() }
    );
}

Loader

typescript
// transloco-loader.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Translation, TranslocoLoader } from '@jsverse/transloco';

@Injectable({ providedIn: 'root' })
export class TranslocoHttpLoader implements TranslocoLoader {
    private http = inject(HttpClient);
    
    getTranslation(lang: string) {
        return this.http.get<Translation>(`/assets/i18n/${lang}.json`);
    }
}

Pluralization (Transloco MessageFormat)

ICU MessageFormat คือภาษากลางที่ใช้เขียนข้อความที่เปลี่ยนแปลงตามจำนวน ตัวอย่าง {count, plural, =0 {ไม่มีรายการ} one {1 รายการ} other {# รายการ}} อ่านได้ว่า ถ้า count เท่ากับ 0 ให้แสดง "ไม่มีรายการ" ถ้าเท่ากับ 1 ให้แสดง "1 รายการ" ถ้ามากกว่านั้นให้แสดง "N รายการ" โดยเครื่องหมาย # จะถูกแทนด้วยค่าจริงของ count ให้อัตโนมัติ

ปัญหาคือ Transloco แบบ default ใช้แค่ {{count}} ธรรมดา ซึ่งใส่ค่าลงไปได้ก็จริง แต่ไม่รองรับ logic แบบ "ถ้าจำนวนเท่านี้ให้แสดงอีกแบบ" เราจึงต้องลง plugin เพิ่มเพื่อให้เขียน ICU แบบด้านบนได้

⚠️ Transloco default ใช้ {{name}} (Mustache — ชื่อเรียก syntax {{...}} ที่มาจากรูปร่างคล้ายหนวด) ไม่รองรับ ICU {count, plural, ...} ตรง ๆ — ต้องลง plugin @jsverse/transloco-messageformat (ชื่อใหม่หลังย้ายจาก @ngneat → @jsverse ปี 2024) ก่อน

📦 Package name ปี 2026: @jsverse/transloco + @jsverse/transloco-messageformat (ของเก่า @ngneat/transloco ยัง alias ได้แต่ deprecated)

bash
npm install @jsverse/transloco-messageformat
# (legacy: npm install @ngneat/transloco-messageformat)
typescript
import { provideTranslocoMessageformat } from '@jsverse/transloco-messageformat';

providers: [
    provideTransloco({ /* ... */ }),
    provideTranslocoMessageformat()
]
json
// en.json
{
    "items": "{count, plural, =0 {No items} =1 {One item} other {# items}}"
}
html
<p>{{ 'items' | transloco:{ count: 5 } }}</p>     <!-- "5 items" -->

Scope (Lazy Loading)

typescript
// admin/admin.routes.ts
import { provideTranslocoScope } from '@jsverse/transloco';

export const adminRoutes: Routes = [
    {
        path: '',
        component: AdminComponent,
        providers: [
            provideTranslocoScope('admin')   // load only when route activate
        ]
    }
];
text
assets/i18n/admin/
├── en.json
├── th.json
└── ja.json

→ สำหรับ Micro-Frontend (MF) หรือแอปขนาดใหญ่: แยกไฟล์แปลตาม module/feature


5. Locale Switcher

เมื่อใช้ Transloco แล้ว เรามักทำปุ่มให้ผู้ใช้เลือกภาษาเอง ตัวอย่างนี้สร้างเมนูเลือกภาษา (พร้อมธงและชื่อภาษา) ที่เรียก setActiveLang() เพื่อสลับทันที และจำค่าที่เลือกไว้ใน localStorage เพื่อใช้ครั้งต่อไป:

⚠️ ตัวอย่างนี้ใช้ Angular Material (MatMenuModule, MatButtonModule) — ต้องรัน ng add @angular/material ก่อน (ดูบทที่ 14) ถ้าไม่มี Material ให้ใช้ <select> + <option> แทน

typescript
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslocoService } from '@jsverse/transloco';
import { MatMenuModule } from '@angular/material/menu';
import { MatButtonModule } from '@angular/material/button';

@Component({
    selector: 'app-lang-switch',
    standalone: true,
    imports: [MatMenuModule, MatButtonModule],
    template: `
        <button mat-button [matMenuTriggerFor]="menu">
            🌐 {{ flag(activeLang()) }} {{ activeLang().toUpperCase() }}
        </button>
        <mat-menu #menu>
            @for (lang of availableLangs; track lang) {
                <button mat-menu-item (click)="change(lang)">
                    {{ flag(lang) }} {{ name(lang) }}
                </button>
            }
        </mat-menu>
    `
})
export class LangSwitchComponent {
    private translocoService = inject(TranslocoService);
    
    availableLangs = ['en', 'th', 'ja'];
    
    activeLang = toSignal(
        this.translocoService.langChanges$,
        { initialValue: this.translocoService.getActiveLang() }
    );
    
    flag(lang: string) {
        return { en: '🇬🇧', th: '🇹🇭', ja: '🇯🇵' }[lang] ?? '🌐';
    }
    
    name(lang: string) {
        return { en: 'English', th: 'ไทย', ja: '日本語' }[lang] ?? lang;
    }
    
    change(lang: string) {
        this.translocoService.setActiveLang(lang);
        localStorage.setItem('lang', lang);
        document.documentElement.lang = lang;
    }
}

Persist + Restore

typescript
// app.config.ts — ส่วนของ providers array ใน export const appConfig: ApplicationConfig = { providers: [...] }
// 🔴 อย่าใส่ localStorage/navigator ใน config object โดยตรง — SSR crash
// (server ไม่มี localStorage หรือ navigator → ReferenceError ตอน render)
provideTransloco({
    config: {
        availableLangs: ['en', 'th', 'ja'],
        defaultLang: 'en',           // static fallback
        // ...
    },
    loader: TranslocoHttpLoader
})

🔴 SSR-safe locale detection:

SSR = Angular รัน code บน server ก่อนส่ง HTML ให้เบราว์เซอร์ — server ไม่มี localStorage หรือ navigator (ของพวกนี้มีแค่ในเบราว์เซอร์) ถ้าเรียกตอน app ตั้งค่า Angular จะ crash ก่อนหน้าจะโหลด ใน section 13 มีโค้ดสำเร็จที่ตรวจก่อนว่ารันบน browser หรือ server ด้วย isPlatformBrowser() — ถ้าใช้ SSR ควรข้ามไปดูก่อน

typescript
// ใน APP_INITIALIZER (DI token ของ Angular สำหรับรันโค้ดก่อน app เปิดใช้งาน) หรือ resolver
import { PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

function detectInitialLang(): string {
    const platformId = inject(PLATFORM_ID);
    if (!isPlatformBrowser(platformId)) {
        // SSR: อ่านจาก request header (Accept-Language) แทน
        return 'en';     // หรือ inject Request token
    }
    return localStorage.getItem('lang')
        ?? navigator.language.split('-')[0]
        ?? 'en';
}

6. Pluralization Deep

การนับพหูพจน์ไม่ได้ง่ายเหมือนภาษาอังกฤษทุกภาษา — บางภาษาไม่มีรูปพหูพจน์เลย (ไทย/ญี่ปุ่น) บางภาษามี 3-4 รูปตามจำนวน (รัสเซีย) เราจึงใช้มาตรฐานสองอย่างนี้ร่วมกัน:

  • ICU MessageFormat (International Components for Unicode) — ภาษากลางสำหรับเขียนข้อความที่ขึ้นกับจำนวน/เพศ เช่น {count, plural, one {1 item} other {# items}}
  • CLDR (Common Locale Data Repository) — ฐานข้อมูลกฎภาษาของ Unicode ที่บอกว่าแต่ละภาษามีรูปพหูพจน์อย่างไร (zero/one/two/few/many/other) เช่น ภาษาไทยมีแค่ other, ภาษารัสเซียมีถึง 4 รูป

เพื่อให้แปลถูกต้องทุกภาษา:

English (2 forms: one, other)

text
"1 item" / "5 items"

Thai, Japanese, Chinese (1 form: other)

text
"1 รายการ" / "5 รายการ"
"1個の項目" / "5個の項目"

Slavic (3-4 forms)

text
Russian:
- 1 файл       (singular)
- 2-4 файла    (few)
- 5-20 файлов  (many)
- 21 файл      (singular again)

CLDR Rules

text
ICU MessageFormat รองรับ Unicode CLDR plural rules:
- zero
- one
- two
- few
- many
- other

ลองดูตัวอย่างจริงว่า CLDR ทำงานอย่างไร สมมุติ count = 21 ในภาษารัสเซีย CLDR บอกว่าตัวเลขที่ลงท้ายด้วย 1 (แต่ไม่ใช่ 11) ให้เข้า category one จึงแสดง "21 файл" ซึ่งเป็นรูปเอกพจน์

จุดนี้ทำให้คนที่พูดอังกฤษงง เพราะเลข 21 ดูเหมือน "มากกว่าหนึ่ง" น่าจะเป็นพหูพจน์ แต่กฎภาษารัสเซียไม่คิดแบบนั้น นี่คือเหตุผลที่ไฟล์แปลภาษารัสเซียต้องมีครบทั้ง one, few, และ many

ส่วนภาษาไทยตรงข้ามกันเลย CLDR ระบุว่าไทยมีแค่ category other เดียว ไม่มีการเปลี่ยนรูปตามจำนวน ดังนั้นไฟล์ภาษาไทยไม่ต้องใส่ one, few, หรือ many เลย:

json
// ไฟล์ภาษารัสเซีย (ru.json) — ต้องมีครบสามรูปเพราะ CLDR กำหนดไว้
{
    "items": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}"
    //                        ↑ ลงท้าย 1 ยกเว้น 11    ↑ ลงท้าย 2-4    ↑ ลงท้าย 5-20   ↑ เคสอื่นๆ
}

// ไฟล์ภาษาไทย (th.json) — other อย่างเดียวพอ เพราะไทยไม่มีการเปลี่ยนรูปตามจำนวน
{
    "items": "{count, plural, =0 {ไม่มีรายการ} other {# รายการ}}"
    //                                                  ↑ # แทนค่า count อัตโนมัติ
}
json
// ⚠️ keys one/few/many คือ CLDR plural category — ใช้งานจริงเฉพาะภาษาที่มี category นั้น
// English source ใช้แค่ one/other (และ =0 สำหรับเคสพิเศษ)
// few/many ใส่ในไฟล์ภาษาที่ต้องการเท่านั้น เช่น ภาษารัสเซีย/โปแลนด์
{
    "items": "{count, plural, =0 {No items} one {1 item} other {# items}}"
}

Angular Built-in Plural

html
<p i18n>{count, plural, =0 {No items} =1 {1 item} other {{{count}} items}}</p>

Transloco Plural

bash
npm install @jsverse/transloco-messageformat
json
{
    "items": "{count, plural, =0 {No items} one {1 item} other {{count} items}}"
}
html
<p>{{ 'items' | transloco:{ count: 5 } }}</p>

7. Number + Date + Currency

นอกจากข้อความ ตัวเลข/วันที่/สกุลเงินก็ต้องแสดงตามแต่ละประเทศ (เช่น 1,234.50 ในอังกฤษ แต่ 1.234,50 ในเยอรมัน) Angular มี pipe ที่ปรับตาม locale ให้ และเบราว์เซอร์ก็มี Intl API ในตัวที่ทรงพลัง (รองรับปฏิทินพุทธ, relative time ฯลฯ) — แนะนำให้ใช้ Intl สำหรับเรื่อง format ที่ไม่ใช่การแปลคำ:

Angular Pipes (CLDR-based)

html
<!-- Date -->
<p>{{ today | date:'medium' }}</p>        <!-- Aug 5, 2026, 2:30 PM -->
<p>{{ today | date:'short' }}</p>          <!-- 8/5/26, 2:30 PM -->
<p>{{ today | date:'full' }}</p>           <!-- Tuesday, August 5, 2026, 2:30:00 PM -->
<p>{{ today | date:'yyyy-MM-dd' }}</p>     <!-- 2026-08-05 -->

<!-- Custom locale -->
<p>{{ today | date:'medium':'':'th' }}</p>     <!-- ส.ค. 5, 2026 -->
<p>{{ today | date:'medium':'':'ja' }}</p>     <!-- 2026/08/05 -->

<!-- Currency -->
<p>{{ price | currency:'THB' }}</p>            <!-- THB100.00 -->
<p>{{ price | currency:'JPY':'symbol':'1.0-0' }}</p>     <!-- ¥100 -->
<p>{{ price | currency:'EUR':'code' }}</p>     <!-- EUR 100.00 -->

<!-- Number -->
<p>{{ count | number:'1.2-2' }}</p>            <!-- 1,234.56 -->
<p>{{ count | number:'1.2-2':'de' }}</p>       <!-- 1.234,56 (German style) -->

<!-- Percent -->
<p>{{ ratio | percent:'1.0-2' }}</p>           <!-- 50% -->

Register Additional Locales

typescript
// app.config.ts (standalone pattern — Angular 17+)
import { ApplicationConfig, LOCALE_ID } from '@angular/core';
import { registerLocaleData } from '@angular/common';
import localeTh from '@angular/common/locales/th';
import localeJa from '@angular/common/locales/ja';

registerLocaleData(localeTh);
registerLocaleData(localeJa);

export const appConfig: ApplicationConfig = {
    providers: [
        { provide: LOCALE_ID, useValue: 'th' }      // app-level locale
    ]
};

💡 เลือกใช้แบบไหน — pipe หรือ Intl API?

  • ✅ ใช้ Angular pipe (| date, | currency, | number) เมื่อแสดงผลใน template ตรงๆ — เขียนสั้น อ่านง่าย และ Angular จัดการ locale ให้อัตโนมัติ
  • ✅ ใช้ Intl API เมื่อต้องการปฏิทินพุทธ (พ.ศ.), ตัวเลขไทย (๑๒๓), relative time (เมื่อวาน/ใน 2 วัน), หรือต้อง format ค่าใน TypeScript โดยไม่ผ่าน template

🇹🇭 Thai locale specifics — ต้องระวัง:

  • Buddhist calendar (พ.ศ.): ต้องระบุ calendar: 'buddhist' หรือใช้ locale extension 'th-TH-u-ca-buddhist' — ของ default 'th'/'th-TH' ใน Angular ใช้ปี ค.ศ. ไม่ใช่ พ.ศ.
  • Thai digits (๑๒๓): locale extension 'th-TH-u-nu-thai' หรือ option numberingSystem: 'thai'
  • Currency ฿ vs THB: default Angular pipe คืน THB100.00 — ต้อง currencyDisplay: 'symbol' (Intl) หรือ registerLocaleData(localeTh) ก่อนถึงจะได้ ฿100.00
typescript
// Date — Buddhist calendar
const fmtBuddhist = new Intl.DateTimeFormat('th-TH-u-ca-buddhist', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
});
fmtBuddhist.format(new Date());         // "5 สิงหาคม 2569" (พ.ศ.)

// Date — Thai digits + Buddhist
const fmtThaiDigits = new Intl.DateTimeFormat('th-TH-u-ca-buddhist-nu-thai', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
});
fmtThaiDigits.format(new Date());       // "๕ สิงหาคม ๒๕๖๙"

// Currency THB ออกเป็นสัญลักษณ์ ฿
const baht = new Intl.NumberFormat('th-TH', {
    style: 'currency',
    currency: 'THB',
    currencyDisplay: 'symbol',
});
baht.format(1234.5);                    // "฿1,234.50"

// วิธีย่อ: ใส่ calendar option ตรง ๆ ใน DateTimeFormat โดยไม่ต้องใช้ locale extension -u-ca-buddhist
const fmt = new Intl.DateTimeFormat('th-TH', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    calendar: 'buddhist',
});
fmt.format(new Date());                 // "5 สิงหาคม 2569"

// Number
const num = new Intl.NumberFormat('ja-JP', {
    style: 'currency',
    currency: 'JPY'
});
num.format(1000);                // "¥1,000"

// Intl.RelativeTimeFormat — แสดงเวลาสัมพัทธ์ เช่น 'เมื่อวาน', 'ใน 2 วัน'
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
rtf.format(-1, 'day');           // "yesterday"
rtf.format(2, 'day');             // "in 2 days"

// Intl.ListFormat — รวมรายการเป็นประโยค เช่น 'apple, banana, and orange'
const list = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
list.format(['apple', 'banana', 'orange']);    // "apple, banana, and orange"

// Plural rules
const pr = new Intl.PluralRules('en');
pr.select(1);    // "one"
pr.select(5);    // "other"

→ ใช้ Intl API สำหรับงานที่ไม่ใช่การแปลคำ (วันที่, ตัวเลข, สกุลเงิน, การเรียงลำดับ)


8. RTL (Right-to-Left)

บางภาษา (อาหรับ, ฮีบรู, เปอร์เซีย) เขียนจากขวาไปซ้าย ทำให้ทั้งหน้าต้อง "กลับด้าน" — เมนู, ไอคอน, ระยะขอบ ต้องสลับซ้าย-ขวา เคล็ดลับสำคัญคือตั้ง dir="rtl" ที่ <html> และใช้ CSS logical properties (margin-inline-start แทน margin-left) เพื่อให้สไตล์ทำงานได้ทั้งสองทิศทางโดยไม่ต้องเขียนซ้ำ:

text
RTL languages:
- Arabic (ar) — ภาษาอาหรับ
- Hebrew (he) — ภาษาฮีบรู
- Persian/Farsi (fa) — ภาษาเปอร์เซีย/ฟาร์ซี
- Urdu (ur) — ภาษาอูรดู
- Yiddish (yi) — ภาษายิดดิช

HTML Direction

html
<html lang="ar" dir="rtl">
    <!-- All content mirrors -->
</html>

<html lang="en" dir="ltr">
    <!-- Default LTR -->
    <span dir="rtl">عربى</span>  <!-- mixed -->
</html>

Dynamic Switching

typescript
// ⚠️ โค้ดนี้ใช้ global `document` โดยตรง — ใช้ได้เฉพาะ browser-only app
// ถ้าใช้ SSR ให้ใช้ inject(DOCUMENT) แทน ดูตัวอย่างที่ section 13 (DirectionService เต็ม)
@Injectable({ providedIn: 'root' })
export class DirectionService {
    private rtlLangs = ['ar', 'he', 'fa', 'ur', 'yi'];
    
    setDirection(lang: string) {
        const isRtl = this.rtlLangs.includes(lang);
        document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
        document.documentElement.lang = lang;
    }
}

// In language switch
change(lang: string) {
    this.translocoService.setActiveLang(lang);
    this.dirService.setDirection(lang);
}

CSS Logical Properties (RTL-aware)

scss
/* ❌ Physical — break in RTL */
.box {
    margin-left: 16px;
    padding-right: 8px;
    text-align: left;
    border-left: 1px solid;
}

/* ✅ Logical — works for both */
.box {
    margin-inline-start: 16px;     /* left in LTR, right in RTL */
    padding-inline-end: 8px;
    text-align: start;
    border-inline-start: 1px solid;
}

CSS logical properties คิดในแง่ "ต้น (start)" และ "ปลาย (end)" ของทิศการอ่าน แทนที่จะยึด "ซ้าย/ขวา" ตายตัว

ใน LTR (อังกฤษ ซึ่งอ่านซ้ายไปขวา) start คือด้านซ้าย ส่วนใน RTL (อาหรับ ซึ่งอ่านขวาไปซ้าย) start คือด้านขวา

ดังนั้น margin-inline-start: 16px แปลว่า "ระยะห่างจากต้นบรรทัด" มันจึงกลายเป็นด้านซ้ายสำหรับอังกฤษ และกลายเป็นด้านขวาสำหรับอาหรับให้เองโดยอัตโนมัติ โดยเราไม่ต้องเขียนโค้ดเพิ่ม

text
LTR (อังกฤษ):
  [☰] Home  About  ← เมนู + hamburger icon อยู่ซ้าย (start)
   ^
   start = ซ้าย

RTL (อาหรับ):
  About  Home [☰] ← เมนู + hamburger icon ย้ายไปขวา (start) อัตโนมัติ
               ^
               start = ขวา

ถ้าเขียน margin-left: 16px hamburger icon จะยังอยู่ซ้ายเสมอแม้ในโหมด RTL — ต้องเขียน override เพิ่ม แต่ถ้าใช้ margin-inline-start: 16px ก็ย้ายไปถูกที่ให้เองโดยไม่ต้องแตะโค้ด

PhysicalLogical
margin-leftmargin-inline-start
margin-rightmargin-inline-end
padding-leftpadding-inline-start
border-leftborder-inline-start
left: 0inset-inline-start: 0
text-align: lefttext-align: start
float: leftfloat: inline-start

Material RTL Support

typescript
// Material auto-detect direction from <html dir> อัตโนมัติ — ไม่ต้อง config เพิ่ม
// ถ้าต้องการ wrap component เฉพาะส่วนให้ใช้ directionality ใน CDK:
// import { Dir } from '@angular/cdk/bidi';
// <div [dir]="currentDir">...</div>

Test RTL

javascript
// ทดสอบเร็ว: เปิด DevTools (F12) แล้วพิมพ์คำสั่งนี้ใน Console
document.documentElement.dir = 'rtl';

→ ตรวจสอบ: icon position, animation direction, scroll, modal alignment


9. Common Locale Tasks

ส่วนนี้รวมงานเกี่ยวกับ locale ที่เจอบ่อยในแอปจริง พร้อมโค้ดสำเร็จ — แปลข้อความ validation, แปล error code จาก backend, เรียงลำดับตามภาษา (ใช้ Intl.Collator ไม่ใช่ .sort() ธรรมดา) และค้นหาแบบไม่สนเครื่องหมายวรรณยุกต์ (ในทีมจริงมักส่งคำแปลผ่าน TMS (Translation Management System — ระบบจัดการงานแปล) เช่น Crowdin, Lokalise — ดู section 14):

Translate Validator Messages

json
// errors.json (transloco)
{
    "errors": {
        "required": "{{field}} is required",
        "email": "Invalid email",
        "minLength": "Minimum {{min}} characters"
    }
}
typescript
import { inject, Injectable } from '@angular/core';
import { ValidationErrors } from '@angular/forms';
import { TranslocoService } from '@jsverse/transloco';

@Injectable({ providedIn: 'root' })
export class FormErrorService {
    private transloco = inject(TranslocoService);
    
    getMessage(field: string, errors: ValidationErrors | null): string {
        if (!errors) return '';
        const fieldName = this.transloco.translate(`fields.${field}`);
        if (errors['required']) {
            return this.transloco.translate('errors.required', { field: fieldName });
        }
        if (errors['email']) {
            return this.transloco.translate('errors.email');
        }
        if (errors['minlength']) {
            return this.transloco.translate('errors.minLength', { 
                min: errors['minlength'].requiredLength 
            });
        }
        return this.transloco.translate('errors.invalid');
    }
}

Translate Backend Error

json
// Backend returns error code
{ "error": "USER_NOT_FOUND" }
typescript
// Frontend translate
const msg = this.transloco.translate(`api.errors.${error}`);

Locale-aware Sorting

typescript
const names = ['Ångström', 'Brown', 'Çelik', 'Dvořák'];

// ❌ Default sort — wrong for non-ASCII characters (byte-order ไม่ใช่ locale-order)
names.sort();    // อาจเรียงผิดสำหรับอักขระพิเศษเช่น Å, Ç, Ř

// ✅ Intl.Collator — แทนที่ 'sv' ด้วย locale ที่เหมาะกับข้อมูล
// เช่น 'sv' สำหรับ Nordic/European, 'th' สำหรับภาษาไทย, 'ja' สำหรับญี่ปุ่น
const collator = new Intl.Collator('sv');
names.sort(collator.compare);

// Or shorthand
names.sort((a, b) => a.localeCompare(b, 'sv'));

// ตัวอย่างภาษาไทย
const thaiNames = ['กุ้ง', 'อ้อย', 'แนน', 'บิ๊ก'];
thaiNames.sort((a, b) => a.localeCompare(b, 'th'));
typescript
function fuzzySearch(items: string[], query: string, locale = 'en'): string[] {
    const normalizedQuery = query.toLowerCase().normalize('NFD');
    
    return items.filter(item => 
        item.toLowerCase().normalize('NFD').includes(normalizedQuery)
    );
}

// "cafe" matches "café" (after NFD normalize)

10. SEO + Localized Routing

เว็บหลายภาษาควรให้แต่ละภาษามี URL ของตัวเอง (เช่น /en/..., /th/...) เพื่อให้ search engine เก็บ index แยกและผู้ใช้แชร์ลิงก์ถูกภาษา ส่วนนี้สอนการใส่ locale ใน route, ใช้ resolver สลับภาษาตาม URL และใส่ hreflang tag เพื่อบอก Google ว่ามีหน้าภาษาอื่น:

Route per Locale

typescript
// Approach: locale in URL
export const routes: Routes = [
    {
        path: ':lang',
        children: [
            { path: '', component: HomeComponent },
            { path: 'about', component: AboutComponent },
            { path: 'products/:slug', component: ProductComponent }
        ],
        resolve: { lang: langResolver }
    },
    { path: '', redirectTo: '/en', pathMatch: 'full' }
];

resolver (ตัวแก้ปัญหาล่วงหน้า) คือโค้ดที่ Angular รันโดยอัตโนมัติ ก่อน component โหลด เหมือน รปภ. ที่ตรวจสอบก่อนให้เข้าห้อง — ในที่นี้ resolver ดึง locale จาก URL (:lang param เช่น /th/about ก็จะได้ th) แล้วตั้งภาษาให้ Transloco ก่อนที่ผู้ใช้จะเห็นหน้า จากนั้น return ค่า lang ให้ component ใช้ต่อผ่าน ActivatedRoute.data

DOCUMENT token = วิธี Angular-safe ในการเข้าถึง document ที่ทำงานได้ทั้งบน browser และ server (SSR) ต่างจากการเรียก document โดยตรงที่จะ crash บน server เพราะ server ไม่มี document object

typescript
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { TranslocoService } from '@jsverse/transloco';

export const langResolver: ResolveFn<string> = (route) => {
    const lang = route.paramMap.get('lang') || 'en';
    const transloco = inject(TranslocoService);
    transloco.setActiveLang(lang);
    // ⚠️ resolver รันบน server ด้วยตอน SSR — ใช้ inject(DOCUMENT) แทน global document
    // (เรียก document ตรง ๆ จะ crash บน server เพราะไม่มี object นี้) เหมือน DirectionService ด้านบน
    inject(DOCUMENT).documentElement.lang = lang;
    return lang;
};

hreflang Tags (SEO)

html
<!-- index.html -->
<link rel="alternate" hreflang="en" href="https://example.com/en">
<link rel="alternate" hreflang="th" href="https://example.com/th">
<link rel="alternate" hreflang="ja" href="https://example.com/ja">
<link rel="alternate" hreflang="x-default" href="https://example.com">
typescript
// ⚠️ Dynamic hreflang — Meta service ใช้ไม่ได้!
// rel/hreflang/href = attribute ของ <link> tag ไม่ใช่ <meta>
// Meta.addTags() รองรับเฉพาะ <meta name|property|content> เท่านั้น

import { DOCUMENT } from '@angular/common';
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Injectable({ providedIn: 'root' })
export class HreflangService {
    private doc = inject(DOCUMENT);
    private platformId = inject(PLATFORM_ID);
    
    setHreflang(links: { lang: string; href: string }[]) {
        if (!isPlatformBrowser(this.platformId)) return;     // SSR-safe — บน server ให้ใส่ hreflang ใน index.html แบบ static แทน (ดูตัวอย่างด้านบน) หรือใช้ Meta/TransferState จาก @angular/ssr
        
        // ลบ link เก่าก่อน
        this.doc.querySelectorAll('link[rel="alternate"][hreflang]').forEach(el => el.remove());
        
        // ใส่ใหม่
        for (const { lang, href } of links) {
            const link = this.doc.createElement('link');
            link.rel = 'alternate';
            link.hreflang = lang;
            link.href = href;
            this.doc.head.appendChild(link);
        }
    }
}

💡 ทางเลือกที่ง่ายกว่า: ถ้า hreflang ไม่เปลี่ยนต่อ page ให้ใส่ static ใน index.html ตอน build (ดูตัวอย่างบนสุดของ section) — ไม่ต้องมี service เลย

Localized URLs (slugs)

text
en: example.com/en/products/laptop
th: example.com/th/สินค้า/แล็ปท็อป
ja: example.com/ja/製品/ラップトップ

→ ดีต่อ SEO + user experience
→ ต้องมี slug mapping ใน backend


11. Best Practices

สรุปแนวปฏิบัติที่ช่วยให้งาน i18n ไม่พังในระยะยาว — เช่น อย่า hardcode ข้อความ, อย่าต่อ string เอง (ลำดับคำแต่ละภาษาต่างกัน), เผื่อพื้นที่ให้ข้อความที่ยาวขึ้น (เยอรมันยาวกว่าอังกฤษ ~30%) และทดสอบด้วย pseudo-locale เพื่อจับข้อความที่ลืมแปล:

text
✅ Never hardcode strings in code — extract everything (อย่า hardcode ข้อความลงใน code)
✅ Use keys, not English text, as identifier (e.g. "user.welcome" not "Welcome") — ใช้ key ไม่ใช่ข้อความ
✅ Provide context to translator (@@id|description) — ให้บริบทแก่นักแปล
✅ Group related keys (header.menu.home, header.menu.about) — จัดกลุ่ม key ที่เกี่ยวข้อง
✅ Use scope/namespace for large apps — แยก namespace สำหรับแอปใหญ่
✅ Pluralization with ICU MessageFormat — ใช้ ICU สำหรับพหูพจน์
✅ Use CSS logical properties (RTL-ready) — ใช้ logical properties รองรับ RTL
✅ Test with RTL even if not shipping (catch issues early) — ทดสอบ RTL แม้ไม่ได้ launch ภาษา RTL
✅ Lazy-load translation files (per route/feature) — โหลดไฟล์แปลแบบ lazy
✅ Cache translations in browser (CDN + cache-control) — cache ไฟล์แปลที่ CDN
✅ Run app in pseudo-locale for QA (ƒáƙé ŁόƈáłιƵé) — ทดสอบด้วย pseudo-locale หา hardcoded strings
✅ Set document.documentElement.lang on switch — ตั้ง lang attribute ทุกครั้งที่เปลี่ยนภาษา

❌ Don't translate at component init (race condition) — อย่าแปลตอน component เริ่มต้น (อาจแปลก่อนไฟล์แปลโหลดเสร็จ)
❌ Don't translate variables (translate at display time) — อย่าแปลค่า variable (แปลตอนแสดงผล)
❌ Don't store translated text in DB (store key + translate frontend) — เก็บ key ไม่ใช่ข้อความแปลลง DB
❌ Don't concatenate strings ("Hello " + name + "!") — use template (อย่าต่อ string เอง)
❌ Don't assume word order — อย่าคิดว่าทุกภาษาเรียงคำเหมือนกัน (เยอรมัน: กริยาอยู่ท้าย, ไทย: ไม่มี verb conjugation)
❌ Don't assume text length (German often 30% longer) — เผื่อพื้นที่ ข้อความบางภาษายาวกว่า 30%
❌ Don't use icons alone (cultural — thumbs up = OK in US, rude in some MEA) — อย่าใช้ icon อย่างเดียว

Pseudo-locale (For QA)

json
// en-XA.json — pseudo
{
    "welcome": "[!! Ŵéłƈόɱé !!]",
    "items": "[!! {{count}} ítéɱś !!]"
}

→ Test app in pseudo — catch hardcoded strings + text overflow


12. ⚠️ Common Mistakes

ตารางนี้รวมข้อผิดพลาดที่พบบ่อยตอนทำ i18n คู่กับวิธีที่ถูกต้อง — หลายข้อเป็นกับดักที่ดูเหมือนทำงานได้ในภาษาอังกฤษ แต่พังทันทีเมื่อเปลี่ยนภาษา:

❌ ผิด✅ ถูกหมายเหตุ
Concatenate string "Hello " + nameUse template "Hello {name}"ลำดับคำแต่ละภาษาต่างกัน
Translate UI date manuallyUse | date or Intl.DateTimeFormatpipe/Intl รองรับ locale อัตโนมัติ
Use Date.toLocaleString() without localePass locale explicitlyผลขึ้นกับ OS locale ของผู้ใช้
Hardcode currency symbol '$'Use | currency:codeสัญลักษณ์แตกต่างตาม locale
Format number .toFixed(2)Use | number (locale-aware), และ . สลับกันในบางภาษา
margin-left everywhereUse margin-inline-startphysical property พังใน RTL
Translate to DBStore key + translate at runtimeเก็บ key ไม่ใช่ข้อความที่แปลแล้ว
Translation in component codeTranslation filesยากต่อการจัดการและส่งนักแปล
Sort with arr.sort()Use Intl.Collatorbyte-order ไม่ใช่ locale-order
Compare with === (umlaut/normalize)Normalize firsté กับ é อาจ ≠ กันก่อน normalize
Forget lang attributeSet document.documentElement.langscreen reader + SEO ต้องการ
Pluralize manually count + " " + (count === 1 ? "item" : "items")Use ICU pluralพังทันทีกับภาษาที่มีหลาย plural form

13. ตัวอย่างเต็ม — Multi-Lang App

รวมทุกอย่างในบทนี้เป็นแอปหลายภาษาที่พร้อมใช้จริง — มีครบทั้งการตรวจหาภาษาเริ่มต้นจากเบราว์เซอร์/localStorage, ตั้งค่า Transloco พร้อม plural, ปุ่มสลับภาษา และจัดการทิศทาง RTL ใช้เป็นแม่แบบของโปรเจกต์หลายภาษาได้เลย:

typescript
// app.config.ts
import { provideTransloco, TranslocoService } from '@jsverse/transloco';
import { provideTranslocoMessageformat } from '@jsverse/transloco-messageformat';
import { ApplicationConfig, provideAppInitializer, inject, PLATFORM_ID, isDevMode } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';  // สร้างโดย Angular CLI ตอน ng new
import { TranslocoHttpLoader } from './transloco-loader';

// ⚠️ อย่าเรียก localStorage/navigator นอก function — server ไม่มี → ReferenceError ตอน SSR
// ต้องเรียกใน APP_INITIALIZER ที่รันหลัง Angular bootstrap (มี PLATFORM_ID check)
// 📖 APP_INITIALIZER / provideAppInitializer = สั่งให้ Angular รันโค้ดนี้ก่อน app เริ่มทำงาน (เตือนความจำจาก section 5 ด้านบน)
// provideAppInitializer (Angular 19+) = วิธีใหม่แทน { provide: APP_INITIALIZER, useFactory: ... } แบบเก่า
// ถ้าใช้ Angular 17-18 ให้ใช้: { provide: APP_INITIALIZER, useFactory: () => () => initLangFactory(), multi: true }
// (useFactory ต้อง return ตัว initializer function จริง ๆ — ถ้าเขียน () => initLangFactory เฉย ๆ จะ return ตัว function โดยไม่เรียก โค้ดจะไม่ทำงาน)
function initLangFactory() {
    // 💡 inject() สองบรรทัดนี้เรียกได้เสมอ ไม่ว่ารันบน browser หรือ server (ปลอดภัยทั้งคู่)
    // มีแค่ localStorage/navigator ด้านล่างเท่านั้นที่ต้องมี guard isPlatformBrowser ป้องกัน
    const platformId = inject(PLATFORM_ID);
    const transloco = inject(TranslocoService);
    
    if (!isPlatformBrowser(platformId)) {
        // SSR: ถ้าต้องอ่าน Accept-Language ให้ inject REQUEST token (Angular SSR)
        return;
    }
    
    const saved = localStorage.getItem('lang');
    const browser = navigator.language.split('-')[0];
    const lang = saved ?? (['en', 'th', 'ja'].includes(browser) ? browser : 'en');
    transloco.setActiveLang(lang);
}

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideHttpClient(),
        
        provideTransloco({
            config: {
                // Transloco รับได้ทั้ง string[] หรือ {id, label}[]
                // ใช้ object form ถ้าจะแสดง label ใน switcher (ดู section 4 ด้านบน ใช้ string form แบบสั้น)
                availableLangs: [
                    { id: 'en', label: 'English' },
                    { id: 'th', label: 'ไทย' },
                    { id: 'ja', label: '日本語' }
                ],
                defaultLang: 'en',                    // ✅ static fallback (SSR-safe)
                fallbackLang: 'en',
                reRenderOnLangChange: true,
                missingHandler: { useFallbackTranslation: true },
                prodMode: !isDevMode()
            },
            loader: TranslocoHttpLoader
        }),
        provideTranslocoMessageformat(),
        
        // ⭐ ตั้งภาษาตามผู้ใช้หลัง bootstrap (browser only)
        provideAppInitializer(initLangFactory)
    ]
};
typescript
// app.component.ts
import { Component, inject, effect } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { RouterOutlet, RouterLink } from '@angular/router';
import { TranslocoModule, TranslocoService } from '@jsverse/transloco';
import { DirectionService } from './direction.service';
import { LangSwitchComponent } from './lang-switch.component';

@Component({
    selector: 'app-root',
    standalone: true,
    imports: [RouterOutlet, RouterLink, TranslocoModule, LangSwitchComponent],
    template: `
        <ng-container *transloco="let t">
            <header>
                <h1>{{ t('app.title') }}</h1>
                <app-lang-switch />
            </header>
            
            <nav>
                <a routerLink="/">{{ t('nav.home') }}</a>
                <a routerLink="/about">{{ t('nav.about') }}</a>
            </nav>
            
            <main>
                <router-outlet />
            </main>
            
            <footer>
                {{ t('footer.copyright', { year: 2026 }) }}
            </footer>
        </ng-container>
    `
})
export class AppComponent {
    private translocoService = inject(TranslocoService);
    private dirService = inject(DirectionService);
    
    // ⚠️ ต้อง declare activeLang ก่อน constructor เพื่อให้ field ถูก initialize ก่อน effect() เรียกใช้
    activeLang = toSignal(this.translocoService.langChanges$);
    
    constructor() {
        // Sync direction with language
        effect(() => {
            const lang = this.activeLang();
            if (lang) {
                this.dirService.setDirection(lang);
            }
        });
    }
}
typescript
// direction.service.ts
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { Injectable, inject, PLATFORM_ID } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class DirectionService {
    private doc = inject(DOCUMENT);
    private platformId = inject(PLATFORM_ID);
    private rtlLangs = new Set(['ar', 'he', 'fa', 'ur']);
    
    setDirection(lang: string) {
        // ⚠️ ฝั่ง SSR ก็ตั้ง dir/lang ได้ (Angular SSR เปิด DOCUMENT ให้)
        // ใช้ DOCUMENT token แทน global document → ทำงานได้ทั้ง browser + server
        const dir = this.rtlLangs.has(lang) ? 'rtl' : 'ltr';
        this.doc.documentElement.dir = dir;
        this.doc.documentElement.lang = lang;
    }
}
json
// assets/i18n/en.json
{
    "app": {
        "title": "My App"
    },
    "nav": {
        "home": "Home",
        "about": "About"
    },
    "footer": {
        "copyright": "© {{year}} My Company"
    },
    "users": {
        "list": "{count, plural, =0 {No users} one {1 user} other {# users}}",
        "greeting": "Hello, {{name}}!"
    },
    "errors": {
        "required": "{{field}} is required",
        "email": "Invalid email",
        "minLength": "Minimum {{min}} characters"
    }
}
json
// assets/i18n/th.json
{
    "app": {
        "title": "แอปของฉัน"
    },
    "nav": {
        "home": "หน้าแรก",
        "about": "เกี่ยวกับ"
    },
    "footer": {
        "copyright": "© {{year}} บริษัทของฉัน"
    },
    "users": {
        "list": "{count, plural, =0 {ไม่มีผู้ใช้} other {# คน}}",
        "greeting": "สวัสดี {{name}}!"
    },
    "errors": {
        "required": "กรุณากรอก {{field}}",
        "email": "อีเมลไม่ถูกต้อง",
        "minLength": "ขั้นต่ำ {{min}} ตัวอักษร"
    }
}

14. Translation Workflow

ในทีมจริง นักพัฒนาไม่ได้แปลเองทุกภาษา แต่ส่งคำให้นักแปลผ่านระบบจัดการการแปล (TMS) เช่น Crowdin หรือ Lokalise แล้วดึงผลลัพธ์กลับมา ส่วนนี้อธิบายขั้นตอนทั้งวงจร และวิธีทำให้มันอัตโนมัติด้วย CI:

text
Dev: เขียน key + ข้อความ English ต้นฉบับ

Translation Management System (TMS) — ระบบจัดการงานแปล:
- Crowdin
- Lokalise
- Phrase
- POEditor
- Localizely

Translator: แปลแต่ละ locale

Sync back: ดาวน์โหลดไฟล์ JSON / XLIFF ที่แปลแล้ว

Deploy

Crowdin Example

bash
# 1. ติดตั้ง Crowdin CLI
npm install -g @crowdin/cli
yaml
# 2. ตั้งค่า crowdin.yml — ใส่ project_id และ api_token จาก Crowdin dashboard
project_id: 'XXXXXX'
api_token: 'XXXXX'

files:
  - source: '/src/assets/i18n/en.json'
    translation: '/src/assets/i18n/%two_letters_code%.json'
bash
# 3. อัปโหลดไฟล์ต้นฉบับ (en.json) ไปให้นักแปล
crowdin upload sources

# 4. ดาวน์โหลดไฟล์แปลที่เสร็จแล้วกลับมา
crowdin download

Continuous Localization (CI)

ตัวอย่างนี้ตั้ง GitHub Actions ให้อัปโหลดไฟล์แปลไป Crowdin อัตโนมัติทุกครั้งที่ push ไฟล์ en.json:

yaml
# .github/workflows/i18n.yml
on:
  push:
    paths: ['src/assets/i18n/en.json']

jobs:
  upload:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @crowdin/cli
      - run: crowdin upload sources

15. Checkpoint

ลองฝึกตามโจทย์ด้านล่างให้ครบทุกแง่มุมของ i18n — ตั้งแต่ built-in i18n, Transloco แบบ runtime, plural/format, RTL ไปจนถึงทดสอบด้วย pseudo-locale:

🛠️ Checkpoint 15.1 — Built-in i18n

  • Mark text with i18n — เพิ่ม attribute i18n ให้ข้อความที่ต้องการแปล
  • Extract messages.xlf — รัน ng extract-i18n ดึงข้อความออกมาเป็นไฟล์
  • Translate to Thai — แปลเป็นภาษาไทยใน messages.th.xlf
  • Build with --localize — build แยกตาม locale

🛠️ Checkpoint 15.2 — Transloco

  • Install + setup with 2-3 langs — ติดตั้งและตั้งค่า 2-3 ภาษา
  • Translate UI (use *transloco directive) — ใช้ directive แปล UI
  • Add language switcher — เพิ่มปุ่มเลือกภาษา
  • Persist choice to localStorage — จำค่าที่เลือกไว้

🛠️ Checkpoint 15.3 — Plural + Format

  • Pluralize with ICU — ใช้ ICU MessageFormat กับพหูพจน์
  • Format date + currency + number per locale — format ตาม locale
  • Use Intl.RelativeTimeFormat — แสดงเวลาสัมพัทธ์ เช่น "เมื่อวาน"

🛠️ Checkpoint 15.4 — RTL

  • Add Arabic/Hebrew translation — เพิ่มไฟล์แปลภาษา RTL
  • Convert CSS to logical properties — เปลี่ยน CSS เป็น logical properties
  • Toggle RTL — verify layout mirrors — ทดสอบว่า layout กลับด้านถูกต้อง

🛠️ Checkpoint 15.5 — Pseudo-locale

  • Create en-XA pseudo translation — สร้างไฟล์ pseudo-locale
  • Run app — find hardcoded strings + overflow — หาข้อความที่ hardcode และ layout แตก
  • Fix issues — แก้ปัญหาที่พบ

16. Resources

แหล่งเรียนรู้ต่อด้าน i18n ที่แนะนำ — ทั้งคู่มือทางการของ Angular, ไลบรารี Transloco และเอกสาร Intl/CLDR สำหรับเรื่อง format ตามภาษา:

Official

TMS

Reference


17. สรุปบท

i18n = preparing code for many languages, l10n = translating + formatting
2 แนวทาง: @angular/localize (compile-time, SEO) vs Transloco/ngx-translate (runtime, flexible)
Built-in: i18n attribute + ng extract-i18n + XLIFF + ng build --localize
Transloco: *transloco directive + JSON files + TranslocoService for runtime switch
Pluralization: ICU MessageFormat ({count, plural, =0 {...} one {...} other {...}})
Date/Number/Currency: Angular pipes (with locale) หรือ Intl API native
RTL: dir="rtl" + CSS logical properties (margin-inline-start)
SEO: locale in URL + hreflang tags + localized slugs
Best practices: extract everything, use keys (not English text), provide context
TMS: Crowdin / Lokalise — workflow translator + developer
✅ Test with pseudo-locale before going to real translation
✅ Set document.documentElement.lang + dir on language change


🎉 จบ Angular Book — ครบ 15 บท!

หลังจากบทที่ 0-15 คุณมี:

#บทสอนอะไร
0Introphilosophy, install, first project, CLI
1Components + Templatesstandalone, template, binding, control flow
2Signals + Statesignal, computed, effect, RxJS interop
3Services + DIservice, DI, providers, InjectionToken
4Routingrouter, guards, lazy, resolver
5Formstemplate-driven, reactive, validation, FormArray
6HTTP + RxJSHttpClient, operators, interceptor
7Change Detection + PerformanceOnPush, signals, zoneless, defer
8TestingJasmine, TestBed, mock, e2e
9SSR + ModernSSR, hydration, defer, deployment
10NgRx + Signal Storestate management (scale)
11Micro-frontendsModule Federation, Native Federation
12Accessibility (a11y)semantic, ARIA, keyboard, CDK a11y
13SecurityXSS, CSRF, CSP, auth pattern
14Material + CDKUI library + behavior primitives
15i18n + l10n + RTLmulti-language + locale + RTL

Roadmap หลังจบหนังสือ

ลำดับทำอะไร
1สร้าง real project ครบ stack (Material + Auth + i18n + SSR + Test)
2Contribute open source (Angular, Material, NgRx)
3เรียน Nx Monorepo สำหรับ multi-app
4เจาะลึก WebSocket/SSE real-time
5เรียน Animation API เชิงลึก (route animation, AnimationBuilder)
6สร้าง custom UI library (Storybook + ng-packagr)
7PWA + Push Notifications + Background Sync
8Web Worker + heavy computation
9Schematic (custom ng generate)
10Performance audit จริง (Lighthouse, WebPageTest, RUM)

"หนังสือเล่มนี้คือจุดเริ่มต้น — ของจริงรอผู้ใช้งานจริง"


← บทที่ 14: Material + CDK | สารบัญ | กลับสารบัญหลัก →