D
P
0
← Semua artikel Read in English

WordPress & PHP di Produksi

Dwibahasa FR/EN Tanpa WPML: Tiga Mekanisme i18n Client-Side di Satu Theme WordPress

· · 7 menit baca
Dwibahasa FR/EN Tanpa WPML: Tiga Mekanisme i18n Client-Side di Satu Theme WordPress

Bagian yang saya kira bakal makan waktu paling lama justru beres dalam sekali duduk. Satu file assets/js/site-i18n.js yang membaca bahasa aktif dari localStorage dengan key site-lang, satu tombol .js-lang yang dipasang di nav desktop sekaligus di menu mobile, lalu sebuah kamus. Klik tombol, ganti bahasa, tulis ulang teksnya. Selesai.

Batasan proyeknya sendiri muat dalam satu kalimat: situsnya harus dwibahasa, Prancis dan Inggris, tanpa plugin translation. Bukan WPML, bukan Polylang. Sisanya terserah saya, dan yang saya pegang cuma theme WordPress custom plus JavaScript di sisi client.

Yang benar-benar panjang adalah pertanyaan sesudah tombolnya jadi: teks yang mana.

Teks di satu halaman tidak datang dari satu tempat

Kalau dilihat sebagai daftar pekerjaan, "terjemahkan halamannya" terdengar seperti satu tugas. Begitu dibongkar, isinya tiga hal yang sifatnya berbeda.

Ada chrome UI, string statis yang saya sendiri yang mengetiknya di template: label nav, heading kolom footer, teks tombol. Ada konten yang datang dari CMS, judul dan excerpt dan bullet kartu yang diketik lewat admin dan bisa berubah kapan saja tanpa saya sentuh kodenya. Dan ada body artikel penuh di halaman detail, HTML utuh yang panjangnya berparagraf-paragraf.

Ketiganya sama-sama harus punya versi Prancis, tapi tidak ada satu pun cara yang nyaman untuk ketiganya sekaligus. Yang jadi akhirnya tiga mekanisme di dalam satu file engine yang sama, dibedakan lewat atribut yang dipakai elemennya.

<!-- 1. string statis, elemen menunjuk ke key kamus -->
<a data-tr="nav.services" href="/services">Services</a>
 
<!-- 2. konten CMS, elemen membawa teks Prancisnya sendiri -->
<h3 data-tr-fr="Notre equipe">Our team</h3>
 
<!-- 3. body artikel, isi container ditukar sekaligus -->
<div data-tr-html="article-body">...</div>

Mekanisme satu: kamus statis lewat data-tr

Ini yang paling lurus. Elemen membawa key, key-nya menunjuk ke entri kamus, dan engine menuliskan hasilnya lewat innerHTML. Saya sengaja pakai innerHTML dan bukan textContent karena sebagian string Prancisnya mengandung tag inline seperti <em>, dan tag itu harus ikut ter-render sebagai markup, bukan ter-escape jadi teks mentah yang kelihatan di layar.

Teks Prancisnya bukan hasil terjemahan saya. Versi lama situsnya memang sudah berbahasa Prancis, dan snapshot HTML-nya saya simpan di folder snapshot/. Semua isi kamus ini saya tarik dari copy asli itu, jadi kalimatnya persis yang sudah dipakai klien.

const dict = {
  fr: {
    'nav.services': 'Nos services',
    'foot.tagline': 'Notre <em>savoir-faire</em>, chaque jour',
  },
};
 
function applyStatic(lang) {
  document.querySelectorAll('[data-tr]').forEach((el) => {
    const val = dict[lang]?.[el.dataset.tr];
    if (val != null) el.innerHTML = val;
  });
}

Mekanisme dua: elemen yang membawa teks Prancisnya sendiri

Kamus statis mentok begitu teksnya datang dari CMS. Judul kartu, excerpt, bullet, durasi, dan role anggota tim itu semua diketik di admin. Saya tidak bisa menempelkan key kamus tetap ke sesuatu yang isinya bisa diganti orang lain sore nanti.

Jadi arahnya saya balik. Bukan elemen yang menunjuk ke kamus, tapi elemen yang membawa versi Prancisnya sendiri di sebuah atribut, dan engine tinggal menukar isinya dengan nilai atribut itu. Bedanya dengan mekanisme pertama, yang ini di-apply lewat textContent, yang tidak mem-parse isinya sebagai markup. Nilai atributnya boleh berasal dari post meta FR, atau dari peta EN ke FR yang ditulis di PHP.

Yang saya pakai peta PHP. Isinya ada di inc/fr-content.php, di-key berdasarkan slug, dan dipecah jadi empat fungsi: satu untuk entri layanan, satu untuk tim, satu untuk harga, dan satu lagi khusus nilai atribut. Sumber teks Prancisnya sama seperti tadi, snapshot situs lama, kali ini dari halaman indeks dan halaman-halaman layanan di dalamnya.

// inc/fr-content.php
function thm_svc_fr( $slug, $field ) {
    $map = array(
        'consultation' => array(
            'title'   => 'Consultation',
            'excerpt' => 'Un premier rendez-vous pour faire le point.',
        ),
    );
    return $map[ $slug ][ $field ] ?? '';
}
 
// di template kartu
<h3 data-tr-fr="<?php echo esc_attr( thm_svc_fr( $slug, 'title' ) ); ?>">
    <?php the_title(); ?>
</h3>

Belakangan klien minta arsip layanannya direstrukturisasi, dengan kategori yang relevan dan urutan yang terasa premium, bukan urutan generik. Restrukturisasi itu memunculkan rail kategori baru, dan label rail-nya ikut butuh peta FR sendiri. Itu saya taruh sebagai map $rail_fr di dalam template arsip, ditambah satu key kamus baru untuk judul rail-nya.

Mekanisme tiga: menukar seluruh body artikel

Dua mekanisme tadi sudah menutup hampir semua halaman. Yang membuatnya kurang adalah halaman detail layanan, yang punya body artikel penuh. Atribut jelas bukan tempat menaruh beberapa paragraf HTML, dan memecahnya jadi puluhan key kamus juga tidak masuk akal.

Mekanisme ketiganya data-tr-html. Container-nya ditandai, versi Prancisnya ditaruh di elemen <template> tersembunyi di halaman yang sama, lalu engine menukar innerHTML container itu dengan isi template. Karena isi <template> tidak dirender dan tidak mengeksekusi apa pun sampai dipakai, versi FR bisa nebeng di halaman yang sama tanpa efek samping.

<div class="i18n-swap" data-tr-html="article-body">
  <p>The English body, rendered by the theme.</p>
  <p>Second paragraph.</p>
</div>
 
<template id="fr-article-body">
  <p>Le texte de l'article en francais.</p>
  <p>Deuxieme paragraphe.</p>
</template>

Container swap itu saya bungkus wrapper display: contents supaya layout section-nya tidak berubah waktu isinya ditukar. Wrapper-nya tetap ada di DOM sebagai target swap, tapi tidak membentuk kotak layout sendiri, jadi anak-anaknya tetap dibaca grid atau flex induknya seolah wrapper itu tidak ada.

.i18n-swap {
  display: contents;
}

Versi Prancis body artikelnya tidak saya tempel manual ke tiap post. Ada skrip tools/seed-fr-blocks.php yang menuliskannya ke post meta _svc_blocks_fr dan _svc_sig_fr, sumbernya lagi-lagi snapshot halaman layanan situs lama. Satu halaman malah seluruh isinya ditukar dengan mekanisme yang sama, bukan cuma body artikelnya, dan untuk halaman itu template FR-nya saya tulis langsung inline.

Ada satu keputusan kecil di sini yang menghemat banyak waktu: FAQ-nya pakai <details> native. Swap innerHTML menghapus node lama dan membuat node baru, jadi accordion berbasis JS bakal kehilangan event listener-nya tiap kali bahasa diganti dan harus di-rebind. <details> tidak butuh rebind sama sekali karena toggle-nya ditangani browser.

Balik ke Inggris tanpa reload

Swap innerHTML itu satu arah. Begitu isi container ditimpa versi Prancis, versi Inggrisnya sudah tidak ada lagi di DOM, dan menekan tombol bahasa sekali lagi tidak akan mengembalikannya sendiri. Jadi engine-nya menyimpan innerHTML versi Inggris di sebuah WeakMap yang di-key ke elemennya, lalu mengembalikan isi itu waktu bahasanya dibalik ke Inggris.

const enCache = new WeakMap();
 
function toFrench(el, frHtml) {
  if (!enCache.has(el)) enCache.set(el, el.innerHTML);
  el.innerHTML = frHtml;
}
 
function toEnglish(el) {
  if (enCache.has(el)) el.innerHTML = enCache.get(el);
}

Konsekuensinya perlu disebut terang-terangan. Engine ini menulis ulang innerHTML di banyak elemen sekaligus, jadi apa pun yang juga menulis ulang DOM di elemen yang sama akan berebut dengan dia, kecuali dijalankan setelah i18n selesai apply. Di theme ini script motion saya daftarkan dengan dependency ke script i18n supaya urutan itu dipaksa oleh engine enqueue WordPress. Saya belajar kenapa itu penting dengan cara yang mahal, dan ceritanya saya tulis terpisah.

Yang sengaja tidak diterjemahkan

Nama diri, nama tempat, alamat, alamat email, dan nama bahasa saya biarkan apa adanya di kedua mode. Barisnya tetap muncul di hasil scan sebagai string yang tidak berubah antara EN dan FR, dan memang begitu seharusnya.

Dari "masih banyak miss" ke 141, lalu ke nol

Saya sempat mengira cakupannya sudah lengkap. Dev bilang masih banyak yang miss, dan "masih banyak" itu bukan sesuatu yang bisa dikerjakan. Jadi saya jalankan scan MCP dengan situs dalam mode FR, dan hasilnya 141 string yang belum diterjemahkan.

Daftar itu yang mengubah keadaannya, karena isinya konkret: footer, mulai dari tagline sampai heading dan link tiap kolom serta dua halaman legal, istilah-istilah di marquee, hint scroll di section pillar, baris jam operasional, poster story versi mobile, kartu layanan yang datang dari CMS, dan role anggota tim. Item pertama yang saya kerjakan sekecil menambahkan data-tr="foot.tagline" di template footer.

Setelah semuanya digarap, scan yang sama di homepage balik dengan nol miss nyata. Dari 141 baris di daftar itu, sisa yang belum diterjemahkan adalah nol. Hit yang masih muncul di scan cuma nama diri dan alamat yang tadi memang sengaja dilewati.

Cakupannya per halaman tidak seragam, dan itu disengaja. Homepage, arsip layanan, dan halaman detail dapat FR penuh, termasuk body artikelnya. Halaman 404, halaman hasil pencarian, dan halaman legal cuma dapat chrome-nya, dengan judul halaman legal diambil dari map berbasis slug. Prosa body halaman legal masih placeholder, sementara halaman why us sudah diterjemahkan penuh.

Pelajaran