Translation Workflow & CI — i18n

Translation Workflow & CI Di production, mengelola terjemahan secara manual tidak scalable. Translation Management Systems (TMS) dan CI/CD integration…

Begitu kamus punya empat bahasa, menerjemahkan bukan lagi urusan satu orang yang mengedit JSON. Ada alur: developer menulis teks Indonesia, alat mengumpulkan key-nya, penerjemah bekerja di platform terjemahan (TMS, Translation Management System), hasilnya kembali ke repo sebagai pull request, lalu CI (pemeriksaan otomatis yang jalan di tiap pull request) mengecek file-nya sebelum digabung. Bayangin Bu Sri mencetak brosur Homestay Kabut dalam empat bahasa. Dia kirim naskah Indonesia ke penerjemah, naskahnya kembali, dan sebelum dicetak 1.000 lembar ada korektor yang membaca tiap halaman: harga hilang, nama tamu salah tulis, tombol kepanjangan. CI adalah korektor itu, dan dia gak pernah capek.

🧪 Tebakan cepat: PR dari TMS membawa file en-US dengan "total_other": "Total for {{count} nights: {{price}}" (satu kurung kurawal hilang). npx i18next-cli status menulis berapa persen untuk en-US: 92%, 100%, atau gagal dengan error sintaks? Jawabannya ada di bagian "Hasil CI di PR dari TMS".

Alurnya, dari key baru sampai terjemahan masuk

Bu Sri menambah baris "Sudah termasuk sarapan" di kartu pesan:

  1. Developer menulis t('breakfast') di kode dan "breakfast": "Sudah termasuk sarapan" di locales/id-ID/booking.json (bahasa sumber), lalu npx i18next-cli extract dan commit.
  2. Upload: begitu masuk main, GitHub Actions mengirim file sumber ke TMS.
  3. Penerjemah menerjemahkan di TMS, lengkap dengan konteks: deskripsi key, batas panjang ("tombol, maks. 32 karakter"), arti slot ({{name}} = nama pemesan), dan screenshot kartunya.
  4. Review: penerjemah kedua menyetujui (approve) terjemahannya.
  5. Download: GitHub Actions mengambil terjemahan ke branch sendiri dan membuka pull request.
  6. CI mengecek PR itu. Merah = gak boleh digabung. Hijau = gabung, lalu deploy.

File terjemahan di repo gak diedit tangan. Sumber kebenarannya TMS: edit langsung di JSON akan tertimpa di download berikutnya.

TMS: tempat penerjemah bekerja

Fungsi utama TMS ada dua: memberi penerjemah antarmuka (editor dengan konteks, screenshot, glosarium, memori terjemahan, review dan approve) dan menyinkronkan file terjemahan dengan repository secara otomatis (unggah teks sumber, unduh terjemahan sebagai PR). TMS gak menerjemahkan sendiri, gak mengganti JSON dengan database, dan gak mengalihkan pengunjung.

TMS Cara pakai Sinkron dengan repo
Crowdin layanan cloud GitHub Action crowdin/github-action, Crowdin CLI
Lokalise layanan cloud integrasi GitHub, Lokalise CLI dan API
Weblate open source (GPL 3.0), bisa di-host sendiri atau versi hosted membaca dan menulis ke repo Git langsung
Tolgee open source (inti Apache 2.0), bisa di-host sendiri atau cloud Tolgee CLI, API, GitHub Action

Keempatnya membaca JSON i18next. Pilih dari yang tim kamu butuhkan (harus di server sendiri? sudah dipakai agensi penerjemah?), bukan dari peringkat.

Sinkron otomatis lewat GitHub Actions

# .github/workflows/crowdin.yml
name: Crowdin
on:
  push: { branches: [main], paths: ['locales/id-ID/**'] }
  schedule: [{ cron: '0 1 * * *' }]   # tiap hari 08.00 WIB
jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: crowdin/github-action@v3
        with:
          upload_sources: true
          download_translations: true
          skip_untranslated_strings: true
          localization_branch_name: l10n_crowdin
          create_pull_request: true
          pull_request_title: 'Terjemahan baru dari Crowdin'
        env:
          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
          CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
          CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

CI yang menggagalkan build

# .github/workflows/i18n.yml
name: i18n
on:
  pull_request:
    paths: ['src/**', 'locales/**', 'scripts/check-i18n.mjs']

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - name: Key hilang per bahasa
        if: ${{ !cancelled() }}
        run: npx i18next-cli status
      - name: Key gak terpakai
        if: ${{ !cancelled() }}
        run: npx i18next-cli status --unused
      - name: Sintaks slot
        if: ${{ !cancelled() }}
        run: node scripts/check-i18n.mjs syntax
      - name: Placeholder sama dengan id-ID
        if: ${{ !cancelled() }}
        run: node scripts/check-i18n.mjs placeholders
      - name: Panjang teks tombol
        if: ${{ !cancelled() }}
        run: node scripts/check-i18n.mjs length

Hasil CI di PR dari TMS

PR pertama dari TMS membawa lima kesalahan. Ini hasil asli tiap langkah:

$ npx i18next-cli status
Translation Progress:
- en-US: [■■■■■■■■■■■■■■■■■■■■] 100% (12/12 keys)
- ja-JP: [■■■■■■■■■■■■■■■■■■□□] 90% (9/10 keys), 1 absent
- ar-EG: [■■■■■■■■■■■■■■■■■■■■] 100% (20/20 keys)
✖ Error: Incomplete translations detected.

$ npx i18next-cli status --unused
[ja-JP/booking] locales/ja-JP/booking.json
  ✗ payAtHomestay
Summary: Found 1 unused key(s). No files were modified.

$ node scripts/check-i18n.mjs syntax
en-US total_other: kurung kurawal slot gak lengkap
  "Total for {{count} nights: {{price}}"
1 masalah (syntax)

status menulis 100% untuk en-US, padahal total_other rusak. Dia cuma menghitung key yang ada dan gak kosong, bukan isinya. Itu jawaban tebakannya. (Tanda pisah sebelum "1 absent" kami tulis sebagai koma.)

Kesalahan dari TMS Ditangkap oleh Yang dilihat tamu kalau lolos (i18next 26.4.3)
ja-JP belum punya cancel status kalimat batal dalam bahasa Inggris dari fallbackLng
ja-JP masih menyimpan payAtHomestay (nama lama book) status --unused gak terlihat, cuma menambah byte
en-US {{count} kurang satu kurung check-i18n syntax "Total for {{count} nights: {{price}}", harganya ikut hilang
ar-EG slot ditulis {{الاسم}} check-i18n placeholders "شكرًا لك يا {{الاسم}}!", nama Rina gak muncul
en-US tombol 56 karakter check-i18n length tombol tiga baris di layar 360 px

Script cek sendiri: sintaks, placeholder, panjang

i18next-cli gak memeriksa isi terjemahan, jadi tiga cek itu ditulis sendiri:

// scripts/check-i18n.mjs: node scripts/check-i18n.mjs syntax | placeholders | length
import { readFileSync } from 'node:fs';

const read = (locale) => JSON.parse(readFileSync(`locales/${locale}/booking.json`, 'utf8'));
const source = read('id-ID');
const MAX = { book: 32 }; // tombol pesan: maks. 32 karakter
const baseKey = (key) => key.replace(/_(zero|one|two|few|many|other)$/, '');
const slots = (text) => [...text.matchAll(/\{\{\s*([^{},\s]+)[^{}]*\}\}/g)].map((m) => `{{${m[1]}}}`);

const checks = {
  syntax: (text) => /[{}]/.test(text.replace(/\{\{[^{}]+\}\}/g, '')) && 'kurung kurawal slot gak lengkap',
  placeholders: (text, key) => {
    const want = slots(source[baseKey(key)] ?? '');
    const got = slots(text);
    const plural = key !== baseKey(key); // bentuk jamak boleh tanpa {{count}}: "ليلة واحدة"
    const bad = got.some((s) => !want.includes(s)) || want.some((s) => !got.includes(s) && !(plural && s === '{{count}}'));
    return bad && `slot ${got.join(' ') || '-'}, sumber ${want.join(' ')}`;
  },
  length: (text, key) => [...text].length > MAX[baseKey(key)] && `${[...text].length} karakter, maks. ${MAX[baseKey(key)]}`,
};

const check = process.argv[2];
let problems = 0;
for (const locale of ['id-ID', 'en-US', 'ja-JP', 'ar-EG']) {
  for (const [key, text] of Object.entries(read(locale))) {
    const error = checks[check](text, key);
    if (error) {
      problems++;
      console.log(`${locale} ${key}: ${error}\n  ${JSON.stringify(text)}`);
    }
  }
}
console.log(problems ? `${problems} masalah (${check})` : `OK (${check})`);
process.exitCode = problems ? 1 : 0;

💡 Kalau kamusnya ICU: formatjs verify

Proyek react-intl atau next-intl punya cek siap pakai: npx formatjs verify --source-locale id-ID --missing-keys --extra-keys --structural-equality lang/*.json (@formatjs/cli 6.16.34). Pada kamus ICU kartu kami dengan kesalahan yang sama, dia menulis cancel hilang dan payAtHomestay berlebih di ja-JP, thanks: Missing variable name in message di ar-EG, dan total: EXPECT_ARGUMENT_CLOSING_BRACE (pesan ICU yang gak bisa di-parse) di en-US, lalu keluar dengan kode 1. Dia juga menandai thanks di en-US: "Different number of variables: [gender, name] vs [name]". Itu bukan salah terjemahan: sumber Indonesia memilih Pak, Bu, atau Kak dari gender, bahasa Inggris gak butuh. --structural-equality meminta variabel yang sama di semua bahasa, jadi tulis {gender, select, other {Thank you, {name}!}}.

⚠️ Jebakan yang sering ketemu

TL;DR: Alur terjemahan: developer menulis key id-ID dan menjalankan extract, GitHub Actions mengunggah sumber ke TMS, penerjemah bekerja dengan konteks (deskripsi, batas panjang, arti slot, screenshot) dan saling review, lalu terjemahan kembali sebagai pull request. TMS (Crowdin, Lokalise, Weblate, Tolgee; dua terakhir bisa di-host sendiri) memberi penerjemah antarmuka dan menyinkronkan file dengan repo otomatis. CI di PR itu wajib merah untuk: key hilang (npx i18next-cli status), key gak terpakai (status --unused), slot rusak, placeholder yang beda dari id-ID, dan teks tombol yang melewati batasnya (script sendiri dengan process.exitCode = 1). status bilang 100% walau isinya rusak. Pakai if: ${{ !cancelled() }} supaya semua masalah tampil sekali jalan, skip_untranslated_strings: true di Crowdin, PAT untuk PR terjemahan, dan perbaiki di TMS, bukan dengan mematikan cek. Untuk kamus ICU: formatjs verify --missing-keys --extra-keys --structural-equality.

Yang akan kamu pelajari