Albet Novendo

Lokalisasi rilis! Implementasi Lokalisasi di Astro

10m

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 />

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:

  1. Filter blog collection untuk fetching hanya termasuk bahasa yang diatur saat ini
  2. 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!