Lokalisasi rilis! Implementasi Lokalisasi di Astro
Diterbitkan pada 3 Feb 2025
Akhirnya setelah berhari-hari, jam, menit, bla bla bla dan kata-kata dramatis lainnya. Yah akhirnya berhasil juga siapin fitur lokalisasi di website ini.
Satu roadmap, kelar. Nah sekarang, ada beberapa hal menarik nih yang pengen aku share. Sesuatu yang aku pelajari, dan temui ketika mencoba mengimplementasikan fitur ini.
Astro Internationalization
Semua bermula dari integrasi fitur Astro i18n bawaan. Dari mengikuti apa yang tertulis di dokumentasi, jika kamu tertarik cek disini: Internationalization Docs.
Nah, Astro Internationalization ini adalah routing-based i18n. Artinya mereka menyediakan cara untuk membuat route yang berbeda untuk setiap bahasa, namun belum termasuk logic terjemahannya. Jadi ya di akhir kita harus implementasi sendiri. Untungnya sih ada resep atau tips gitulah dari dokumentasi resminya yang bisa diikuti: Adding i18n Feature.
Jadi berdasarkan resep tersebut, aku mengikuti untuk mengimplementasi terjemahan. Jadi sepanjang post ini, aku akan berbagi bagaimana aku mengimplementasi terjemahan dengan beberapa modifikasi tentunya.
Persiapan
Hal pertama yang harus di lakukan itu yang konfigurasi i18n di file astro.config.mjs
dengan mengisi object i18n
:
export default defineConfig({ // ... konfig lainnya i18n: { locales: ['en', 'id'], defaultLocale: 'en', },})
Lalu, barulah kita memindahkan struktur halaman di direktori src/pages
sesuai dengan bahasa.
Kurang lebih strukturnya seperti ini:
src/ pages/ [lang]/ index.astro other.astro index.astro 404.astro
Catatan
Sebenarnya si i18n
ini punya routing.prefixDefaultLocale
, aku saranin sih di set aja jadi true
atau biarin default,
karena kalo di set jadi false
bakal bikin ribet. Karena nanti harus di adjust dengan kondisional kek gini:
if(url.pathname.includes('en')) { // jangan nambah prefix}
Contoh kasusnya kalo misalnya mau bikin tombol ganti bahasa, ya harus konfigurasi redirect dengan kondisional kek di atas. Terus juga gak bisa pake struktur kek di atas juga sih.
Dari yang bisa dilihat di dokumentasi, Astro i18n meminta kita untuk membagi route berdasarkan bahasa. Misalnya,
jika kita punya halaman blog kita harus membuat route yang berbeda untuk setiap bahasa, misalnya /en/blog
dan
/id/blog
. Bisa aja tinggal kita copy file dan terjemahkan, tapi ini justru malah menghasilkan banyak kode yang
duplikat. Selain itu, aku gak ngerasa ada manfaat apa-apa dari itu juga karenae emang sebagian besar konten di websiteku
berdasarkan komponen.
Nah maka dari itu, digunakan lah dynamic route generation dengan getStaticPaths
. Dengan ini aku bisa membuat satu file
dan generate route berdasarkan bahasa yang aku punya. Kurang lebih kek ginilah:
export function getStaticPaths() { return LANGUAGES.map((lang) => { return { params: { lang, }, } })}
Tip
Saran, kode di atas bisa di jadikan fungsi helper, jadi bisa di reuse di halaman lain. Dengan begitu, tinggal import fungsi tersebut
dan panggil di getStaticPaths
, done.
LANGUAGES
adalah array bahasa yang aku punya, dalam kasus ini ['en', 'id']
. Jadinya ya /en
dan /id
lah yang di generate.
Sekarang, tinggal buat satu file aja dan pakai parameter lang
untuk menentukan bahasa konten.
Dari solusi yang ada di atas masingm-masing punya pro dan kontra, contohnya kalau yang duplikat itu kita bisa aja bikin layout yang berbeda untuk setiap bahasa. Ini bagus banget kalo misalnya string terjemahan panjang dan bikin overflow. Kamu bisa aja langsung adjust classnya.
Disisi lain, kalau sebagian besar kode sama (hanya ganti text), ya harus maintain dua file. Karena itu aku memilih dynamic karena ya lebih maintainable. Walaupun akhirnya aku tetap adjust layout biar tetep readable dan gak overflow.
Sebenarnya sih masih bisa aja pake dynamic generation untuk konfigurasi layout dengan ngecek params lang
dan adjust classnya.
Semua tergantung preferensi sih. Tapi kalau layoutnya emang beda jauh ya langsung aja copy file.
Restorasi URL
Setelah routing selesai, selanjutnya ada hal penting yang harus dilakukan. Restorasi URL. Ini akan memungkinkan pengujung untuk mengunjungi URL apa pun tanpa menyertakan bahasa, setelah mereka mengunjungi halaman ini, website akan mengarahkan mereka ke URL dengan bahasa disertakan. Ini penting untuk memastikan website tetap berfungsi meskipun tidak memiliki bahasa yang diatur sebelumnya.
Untuk melakukan itu, kita cuman perlu untuk menangkap semua routes tanpa bahasa dengan cara menambahkan file [...otherLocales]
:
src/ pages/ [lang]/ index.astro other.astro [...otherLocales].astro index.astro 404.astro
Disitu, kita akan konfigurasi di Astro Script untuk redirect user berdasarkan bahasa default:
import type { GetStaticPaths } from "astro";import { getCollection } from "astro:content";
export const getStaticPaths = (async () => { const paths = ['/', '/other'];
return paths.map((path) => { return { params: { otherLocales: path, }, props: { redirectPath: path, } }; });}) satisfies GetStaticPaths;---
<meta http-equiv="refresh" content={`0;url=/en${Astro.props.redirectPath}`} />
Kode di atas berisi variable paths
yang berisi array path yang harus kita tangkap karena kita masih ingin website mengembalikan 404
kalau path tidak ditemukan. redirectPath
adalah path yang ingin kita redirect. Tag meta
digunakan untuk redirect user ke path yang benar.
0
di atribut content
adalah waktu dalam detik sebelum redirect terjadi. Kamu bisa adjust sesuai keinginan.
Dan selesailah redirect, tentu saja dengan SSR kamu bisa menggunakan server response. Tapi karena aku gak enable SSR, ya aku make meta tag aja.
Di meta tag http-equiv, kamu bisa aja make Astro.preferredLocale
dan ganti en
dengan itu. Saran aku sih cek dulu apakah preferred locale ada di LANGUAGES
sebelum diganti. Dengan begitu kamu bisa pastikan user akan di redirect ke locale yang benar. Atau alternatifnya, coba implementasi Astro.preferredLocaleList
sebagai fallback kalau preferred locale gak ada di LANGUAGES
. Tapi ingat, Astro.prefferedLocale
dan Astro.preferredLocaleList
gak tersedia
di pre-rendered, kamu harus On Demand Rendering. Jadi harus SSR.
Terjemahan
Oke sekarang Restorasi URL udah, kita bisa lanjut ke terjemahan. Terjemahan itu sendiri cukup sederhana, aku cuman bikin beberapa fungsi helper sesuai dengan resep dengan beberapa modifikasi untuk kebutuhan aku.
Pertama, definisikan bahasa yang akan di support:
export const LANGUAGES = ['en', 'id'] as const
Pastikan untuk menambahkan as const
di akhir. Ini akan membuat mereka menjadi const
sehingga bisa di gunakan sebagai tipe.
Selanjutnya, definisikan object terjemahan:
export const translations = { en: { }, id: { },}
Key dari object translations
akan menjadi key untuk mendapatkan terjemahan. Key ini sama dengan key yang ada di LANGUAGES
. Untuk isinya,
aku sendiri lebih suka menggunakan konvensi $page.$key
untuk memudahkan mencari terjemahan. Misalnya, terjemahan untuk judul halaman blog
akan menjadi blog.title
.
Lanjut ke fungsi helper untuk mendapatkan teks terjemahan, aku memutuskan untuk membuat fungsi yang mirip dengan hooks:
function getLang(url: URL) { const [, lang] = url.pathname.split('/')
if (LANGUAGES.includes(lang as SUPPORTED_LANGUAGES)) { return lang as T }
return LANGUAGES[0] as T}
export function useTranslation<T extends SUPPORTED_LANGUAGES>(url: URL) { const lang = getLang<T>(url)
const t = (key: keyof typeof translations[T]) => { return translations[lang][key] }
const getRelativeUrl = (path: string) => {...}
return { t, lang }}
Pemakaian di Astro bakal kayak gini:
const {t} = useTranslation(Astro.url)
Kenapa ga langsung ambil lang
dari parameter aja? Ya karena kalo kode kamu ada getStaticPaths
di Astro Scripts, ya bisa. Tapi,
begitu kamu pindah ke Komponen yang gak punya akses langsung ke lang
, Astro.params.lang
bakal jadi undefined
atau string
. Ini bisa
menyebabkan error runtime. Maka dari itu, aku memutuskan untuk mengambil Astro.url
saja, karena mereka akan selalu terdefinisi.
Dengan begitu, nikmatilah fitur terjemahan dengan dukungan Typescript! Kamu bisa gunakan di Astro Script atau di View:
<h1>{t('blog.title')}</h1><p>{t('blog.description')}</p>
<slot />
Navigasi
Dengan konten yang udah di terjemahkan, langkah selanjutnya adalah membuat navigasi sambil tetap memperhatikan bahasa. Astro sendiri menyediakan
helper getRelativeLocaleUrl
. Namun, implementasi mereka
akan selalu ada trailing slash di akhir. Contoh:
import { getRelativeLocaleUrl } from 'astro:i18n';
getRelativeLocaleUrl('en', 'blog') // /en/blog/
Sebelumnya, aku udah coba cari Github Issues terkait ini dan menemukan cara untuk hanya mengkonfigurasi Trailing Slash.
Namun, ini akan mempengaruhi routing juga, jika aku set ke never
maka url seperti /blog/
akan mengembalikan 404. Maka dari itu, aku memutuskan
untuk membuat fungsi helper sendiri yang sesuai dengan kebutuhan aku:
export function useTranslation() { // ... kode sebelumnya
const getRelativeUrl = () => { const paths = [`/${lang}`]
if (path !== '/') { let normalizedPath = path.startsWith('/') ? path.substring(1) : path
if (normalizedPath.endsWith('/')) { normalizedPath = normalizedPath.substring(0, normalizedPath.length - 1) }
paths.push(normalizedPath) }
return paths.join('/') }
return {t, lang, getRelativeUrl}}
Code nya agak berantakan tapi bisa jalan. Fungsi ini akan menghasilkan url tanpa trailing slash (mungkin?). Penggunaan nya akan seperti ini:
const {getRelativeUrl} = useTranslation(Astro.url)
getRelativeUrl('blog') // /en/blog | deteksi otomatis dari `useLang`.
Wuenak kan? Aku juga gak perlu nambahin /
di setiap path yang aku mau. Cukup panggil fungsi ini dan selesai.
Membuat switch bahasa
Dengan lokalisasi diaktifkan, kamu mungkin ingin memberikan pengguna kemampuan untuk mengubah bahasa. Nah disinilah switch bahasa berguna. Switch bahasa adalah komponen sederhana yang akan memungkinkan pengguna untuk mengubah bahasa. Berikut cara aku melakukannya:
<select class="lang-switcher" data-lang-switcher> <option selected={lang === "en"} value="en">English</option> <option selected={lang === "id"} value="id">Indonesia</option></select>
Lihat kalau aku menggunakan data-lang-switcher
bukan id
. Alasannya adalah karena komponen ini digunakan di beberapa tempat di website. Jadi
menggunakan id yang seharusnya unik akan menyebabkan masalah. Dengan data-lang-switcher
aku bisa menggunakan komponen ini beberapa kali tanpa masalah.
Selanjutnya, kita perlu mengikatkan di script script…
const langSwitcher = document.querySelectorAll<HTMLSelectElement>( "select[data-lang-switcher]",);
langSwitcher.forEach((el) => el.addEventListener("change", (e) => { const selectedLang = el.value;
const url = new URL(location.href);
url.pathname = url.pathname.replace( /^\/[a-z]{2}/, `/${selectedLang}`, ); location.pathname = url.pathname; }),);
Lihat bagaimana aku menggunakan querySelectorAll
untuk memastikan semua instance dari switch bahasa akan di bind. Script ini akan mendengarkan event change
dan kemudian mengubah bahasa berdasarkan nilai yang dipilih. Script ini kemudian akan mengubah pathname dari URL ke bahasa yang dipilih berdasarkan
nilai option
.
Terjemahan di Blog
Info
Sebelum membaca ini, aku sarankan untuk mengenal terlebih dahulu Astro Content Collections.
Oke, sekarang kita pindah ke halaman blog. Halaman blog sedikit berbeda dari halaman lain, karena akan memiliki daftar blog yang dihasilkan secara statis
berdasarkan koleksi. getStaticPaths()
harus mengembalikan koleksi blog sambil memberikan prefix dengan bahasa, sehingga halaman blog akan memiliki route
yang berbeda untuk setiap bahasa. Oleh karena itu, aku perlu menyesuaikan fungsi getStaticPaths
untuk menyertakan halaman blog:
export function getStaticPaths() { const blogPaths = blogs.flatMap((blog) => { return LANGUAGES.map((lang) => { return { params: { lang, slug: blog.slug, }, } }) })
return blogPaths}
Perubahan ini masih belum cukup, karena konten blog masih tetap sama, oleh karena itu aku perlu menyesuaikan struktur direktori blog untuk menyertakan bahasa.
src/ blogs/ en/ blog1.mdx blog2.mdx id/ blog1.mdx blog2.mdx
Setelah struktur disesuaikan, koleksi dari blog akan mengembalikan id seperti en/blog1
dan id/blog1
. Sekarang kita punya 2 pilihan:
- Filter blog collection untuk fetching hanya termasuk bahasa yang diatur saat ini
- Menampilkan semua blog
Aku sendiri lebih suka menampilkan semua blog, karena aku bisa memiliki blog yang hanya ada di satu bahasa dan lainnya. Aku masih ingin memberitahu pengguna bahwa blog ini hanya ada di bahasa lain yang ditunjukkan oleh Badge di samping tanggal. Jadi pengguna masih bisa membacanya jika mereka mau.
Sementara untuk blog dengan dukungan banyak bahasa, disinilah aku ingin memfilter hanya blog dengan bahasa saat ini, karena kita memiliki terjemahan untuk blog tersebut.
Dengan pendekatan kedua, tidak perlu penyesuaian, tetapi jika kamu memilih pendekatan pertama kamu mungkin ingin menghapus bahasa dari URL, untuk itu kamu harus inject bahasa ke dalam pembuatan URL.
<a href={getUrlWithoutLocale(blog.id)}>{blog.title}</a>
getUrlWithoutLocale
adalah fungsi helper yang akan menghapus bahasa dari URL. Selanjutnya, di view blog:
import { getEntry } from "astro:content";
const blog = await getEntry("blogs", `${Astro.params.lang}/${Astro.params.slug}`);
Tinggal inject bahasa yang digunakan saat ini ke getEntry
dan selesai. Dengan begitu, hanya blog yang terlokalisasi yang akan ditampilkan.
Kesimpulan
Astro i18n adalah fondasi yang bagus untuk memulai, tetapi masih memerlukan beberapa pekerjaan manual untuk mengimplementasikan terjemahan. Menggunakan dynamic route generation memungkinkan saya untuk memiliki satu file untuk setiap halaman, yang lebih mudah untuk dipelihara bagi saya. Terjemahan itu sendiri sederhana, cukup buat objek terjemahan dan fungsi helper untuk mendapatkan teks terjemahan. Switch bahasa adalah komponen sederhana yang memungkinkan pengguna untuk mengubah bahasa. Halaman blog perlu disesuaikan karena akan memiliki daftar blog yang dihasilkan secara statis berdasarkan koleksi.
Bagaimanapun, mengimplementasikan lokalisasi di Astro adalah pengalaman yang menyenangkan. Semoga postingan ini bisa membantu kamu untuk mengimplementasikan lokalisasi di website Astro kamu.
Update Lain
Selain lokalisasi, ada beberapa update lain yang aku lakukan:
- Project Scroll Bar (Sekarang di enable karena ada beberapa project yang ke snap)
- Bun v1.2 Upgrades (Pake text based lock file aja sih, tapi tetap enak)
Itu aja buat sekarang, sampai jumpa lagi!