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-USdengan"total_other": "Total for {{count} nights: {{price}}"(satu kurung kurawal hilang).npx i18next-cli statusmenulis berapa persen untuken-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:
- Developer menulis
t('breakfast')di kode dan"breakfast": "Sudah termasuk sarapan"dilocales/id-ID/booking.json(bahasa sumber), lalunpx i18next-cli extractdan commit. - Upload: begitu masuk
main, GitHub Actions mengirim file sumber ke TMS. - Penerjemah menerjemahkan di TMS, lengkap dengan konteks: deskripsi key, batas panjang ("tombol, maks. 32 karakter"), arti slot (
{{name}}= nama pemesan), dan screenshot kartunya. - Review: penerjemah kedua menyetujui (approve) terjemahannya.
- Download: GitHub Actions mengambil terjemahan ke branch sendiri dan membuka pull request.
- 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 }}
- Versi per Oktober 2026:
actions/checkout@v7,crowdin/github-action@v3(jalan di Crowdin CLI 5). skip_untranslated_strings: true: tanpa ini, Crowdin mengisi string yang belum diterjemahkan dengan teks sumber, jadi kartu Jepang bisa memuat kalimat Indonesia dan semua cek jumlah key tetap lolos.GH_TOKENadalah token pribadi (PAT), bukanGITHUB_TOKENbawaan. PR yang dibuat denganGITHUB_TOKENbawaan gak memicu workflow lain, jadi CI di bawah gak akan jalan di PR terjemahan.
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
- Tiap langkah yang keluar dengan kode selain 0 membuat job merah, dan GitHub bisa mewajibkan job ini hijau sebelum merge (branch protection).
if: ${{ !cancelled() }}: langkah berikutnya tetap jalan walau langkah sebelumnya gagal, jadi satu kali run menampilkan semua masalah, bukan cuma yang pertama.npx i18next-cli status(i18next-cli 1.75.2) keluar dengan kode 1 kalau ada key yang tidak ada (absent) atau kosong (untranslated) di salah satu bahasa.status --unusedkeluar dengan kode 1 kalau ada key yang gak dipakai kode.- Satu langkah tambahan yang berguna:
npx i18next-cli extract --ci --dry-rungagal kalau developer lupa menjalankanextractsesudah menambah key.
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;
- syntax: buang semua slot yang utuh (
{{price}}); kalau masih ada{atau}, ada slot yang rusak. - placeholders: tiap slot di terjemahan harus ada di sumber
id-ID({{الاسم}}gak ada), dan tiap slot sumber harus ikut, kecuali{{count}}di bentuk jamak:ليلة واحدة(satu malam) memang gak menulis angkanya. - length:
[...text].lengthmenghitung karakter Unicode. Untuk bahasa Jepang, jumlah karakter belum tentu sama dengan lebar di layar; cek visualnya di pelajaran Testing i18n. process.exitCode = 1yang membuat langkah CI merah.
💡 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
- Mematikan langkah CI supaya hijau. Kesalahannya tetap sampai ke tamu:
{{الاسم}}tampil apa adanya di kartu Arab. Perbaiki di TMS, lalu download ulang. - Percaya angka 100%.
statusmenghitung key, bukan isi. Sintaks, slot, dan panjang butuh cek sendiri. - TMS mengisi yang kosong dengan teks sumber. Pasang
skip_untranslated_strings: true, supaya key yang belum diterjemahkan benar-benar hilang dan ditangkapstatus. - Mengedit JSON terjemahan langsung di repo. Download berikutnya menimpanya. Perbaiki di TMS.
- PR terjemahan dibuat dengan
GITHUB_TOKENbawaan. CI-nya gak jalan sama sekali. Pakai PAT atau token GitHub App.
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.