feat(i18n): katalog id/en — semua string UI via T()/Tf(), fallback en, TIA_LANG sementara + e2e English
CI / test (push) Successful in 24s
CI / test (push) Successful in 24s
This commit is contained in:
1 parent
76cdb0b5bb
commit
19ae0d1c29
13 files changed
+540
-76
No files matched your search
@@ -0,0 +1,162 @@
|
||||
// Package i18n — katalog bahasa (F-023). Bahasa Indonesia default,
|
||||
// fallback otomatis ke English bila kunci belum ada di katalog aktif.
|
||||
// Katalog di-embed ke binary (tetap single binary).
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed locales/*.yaml
|
||||
var locales embed.FS
|
||||
|
||||
const (
|
||||
DefaultLang = "id"
|
||||
FallbackLang = "en"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
active = DefaultLang
|
||||
catalogs = map[string]map[string]string{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
for _, lang := range []string{DefaultLang, FallbackLang} {
|
||||
if c, err := load(lang); err == nil {
|
||||
catalogs[lang] = c
|
||||
}
|
||||
}
|
||||
if l := os.Getenv("TIA_LANG"); l != "" {
|
||||
active = l
|
||||
}
|
||||
}
|
||||
|
||||
// shortcut: bahasa hanya dari TIA_LANG + default id — akan dibaca dari
|
||||
// config.yaml (ui.language) begitu internal/config masuk (Phase 1).
|
||||
func load(lang string) (map[string]string, error) {
|
||||
b, err := locales.ReadFile("locales/" + lang + ".yaml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := yaml.Unmarshal(b, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
flatten("", raw, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func flatten(prefix string, in map[string]any, out map[string]string) {
|
||||
for k, v := range in {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch vv := v.(type) {
|
||||
case map[string]any:
|
||||
flatten(key, vv, out)
|
||||
case string:
|
||||
out[key] = vv
|
||||
default:
|
||||
out[key] = fmt.Sprint(vv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetLang mengganti bahasa aktif (dipakai wizard & layar Pengaturan).
|
||||
func SetLang(lang string) {
|
||||
mu.Lock()
|
||||
active = lang
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func Lang() string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return active
|
||||
}
|
||||
|
||||
// T — terjemahan; fallback: bahasa aktif → en → key itu sendiri.
|
||||
func T(key string) string {
|
||||
mu.RLock()
|
||||
lang := active
|
||||
mu.RUnlock()
|
||||
if v, ok := catalogs[lang][key]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := catalogs[FallbackLang][key]; ok {
|
||||
return v
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Tf — T + fmt.Sprintf.
|
||||
func Tf(key string, args ...any) string {
|
||||
s := T(key)
|
||||
if len(args) == 0 {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf(s, args...)
|
||||
}
|
||||
|
||||
// TOr — T dengan fallback eksplisit (mis. nama state Docker apa adanya).
|
||||
func TOr(key, fallback string) string {
|
||||
mu.RLock()
|
||||
lang := active
|
||||
mu.RUnlock()
|
||||
if v, ok := catalogs[lang][key]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := catalogs[FallbackLang][key]; ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Keys — semua kunci pada katalog lang (untuk tes paritas katalog).
|
||||
func Keys(lang string) []string {
|
||||
out := make([]string, 0, len(catalogs[lang]))
|
||||
for k := range catalogs[lang] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// --- nama hari & bulan (locale data) ---
|
||||
|
||||
var weekdays = map[string][]string{
|
||||
"id": {"Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"},
|
||||
"en": {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"},
|
||||
}
|
||||
|
||||
var months = map[string][]string{
|
||||
"id": {"Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"},
|
||||
"en": {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"},
|
||||
}
|
||||
|
||||
func WeekdayName(t time.Time) string { return pick(weekdays, int(t.Weekday())) }
|
||||
func MonthName(t time.Time) string { return pick(months, int(t.Month())-1) }
|
||||
|
||||
func pick(m map[string][]string, i int) string {
|
||||
mu.RLock()
|
||||
lang := active
|
||||
mu.RUnlock()
|
||||
arr, ok := m[lang]
|
||||
if !ok {
|
||||
arr = m[FallbackLang]
|
||||
}
|
||||
if i < 0 || i >= len(arr) {
|
||||
return ""
|
||||
}
|
||||
return arr[i]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Paritas katalog: setiap kunci harus ada di kedua bahasa.
|
||||
func TestCatalogParity(t *testing.T) {
|
||||
id := Keys("id")
|
||||
en := Keys("en")
|
||||
if len(id) == 0 || len(en) == 0 {
|
||||
t.Fatalf("katalog tidak termuat: id=%d en=%d", len(id), len(en))
|
||||
}
|
||||
inEN := map[string]bool{}
|
||||
for _, k := range en {
|
||||
inEN[k] = true
|
||||
}
|
||||
inID := map[string]bool{}
|
||||
for _, k := range id {
|
||||
inID[k] = true
|
||||
}
|
||||
for _, k := range id {
|
||||
if !inEN[k] {
|
||||
t.Errorf("kunci %q ada di id tapi tidak di en", k)
|
||||
}
|
||||
}
|
||||
for _, k := range en {
|
||||
if !inID[k] {
|
||||
t.Errorf("kunci %q ada di en tapi tidak di id", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFallback(t *testing.T) {
|
||||
SetLang("id")
|
||||
if got := T("nav.home"); got != "beranda" {
|
||||
t.Fatalf("id nav.home = %q", got)
|
||||
}
|
||||
SetLang("en")
|
||||
if got := T("nav.home"); got != "home" {
|
||||
t.Fatalf("en nav.home = %q", got)
|
||||
}
|
||||
// fallback: kunci tak dikenal → key itu sendiri
|
||||
if got := T("tidak.ada"); got != "tidak.ada" {
|
||||
t.Fatalf("fallback = %q", got)
|
||||
}
|
||||
if got := TOr("state.tidakada", "raw"); got != "raw" {
|
||||
t.Fatalf("TOr = %q", got)
|
||||
}
|
||||
SetLang(DefaultLang)
|
||||
}
|
||||
|
||||
func TestTf(t *testing.T) {
|
||||
SetLang("id")
|
||||
got := Tf("fmt.countTotal", 7)
|
||||
if got != "7 total" {
|
||||
t.Fatalf("Tf = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleDates(t *testing.T) {
|
||||
d := time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC) // Kamis
|
||||
SetLang("id")
|
||||
if WeekdayName(d) != "Kamis" || MonthName(d) != "Oktober" {
|
||||
t.Fatalf("id: %s %s", WeekdayName(d), MonthName(d))
|
||||
}
|
||||
SetLang("en")
|
||||
if WeekdayName(d) != "Thursday" || MonthName(d) != "October" {
|
||||
t.Fatalf("en: %s %s", WeekdayName(d), MonthName(d))
|
||||
}
|
||||
SetLang(DefaultLang)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# English catalog (fallback) — F-023
|
||||
# Rule: technical terms (Env, Image, Volume, Stack, Shell, CPU, RAM, PID, Log,
|
||||
# Docker, API) are not translated.
|
||||
|
||||
nav:
|
||||
home: "home"
|
||||
containers: "containers"
|
||||
|
||||
section:
|
||||
clock: "Clock"
|
||||
host: "Host"
|
||||
containers: "Containers"
|
||||
activity: "Activity"
|
||||
detail: "Detail"
|
||||
|
||||
host:
|
||||
local: "local"
|
||||
|
||||
field:
|
||||
id: "ID"
|
||||
image: "Image"
|
||||
state: "State"
|
||||
status: "Status"
|
||||
host: "Host"
|
||||
cpu: "CPU"
|
||||
ram: "RAM"
|
||||
uptime: "Uptime"
|
||||
engine: "Engine"
|
||||
api: "API"
|
||||
|
||||
state:
|
||||
loading: "loading…"
|
||||
noContainers: "no containers yet"
|
||||
connected: "● connected"
|
||||
running: "running"
|
||||
exited: "stopped"
|
||||
paused: "paused"
|
||||
restarting: "restarting"
|
||||
created: "created"
|
||||
dead: "dead"
|
||||
|
||||
error:
|
||||
fail: "FAILED"
|
||||
warn: "WARNING"
|
||||
ok: "OK"
|
||||
loadFailed: "Failed to load"
|
||||
loadContainers: "Failed to load container list"
|
||||
|
||||
hint:
|
||||
retry: "press r to retry"
|
||||
noContainers: "No containers yet — run something with Docker, then press r."
|
||||
miniNoContainers: "no containers yet — press 1"
|
||||
tabsComing: "Detail tabs (Logs · Stats · Config) coming in Phase 2."
|
||||
footerHome: "[1/enter] containers · [t] toast · [a] reduced-motion · [q] quit"
|
||||
footerContainers: "[j/k] select · [r] reload · [0/esc] home · [q] quit"
|
||||
|
||||
footer:
|
||||
status: "%s animation %s fps · audio: %s"
|
||||
|
||||
toast:
|
||||
demoTitle: "✔ [ SUCCESS ] Conversion Saved"
|
||||
demoBody: "Stack 'web-app' saved to ~/.tia/stacks/web-app/"
|
||||
dockerError: "✖ [ FAILED ] Docker"
|
||||
|
||||
activity:
|
||||
coming: "Docker events listener coming (Phase 2)."
|
||||
willShow: "This panel will show the 5 most recent events:"
|
||||
examples: "container start/stop, image pulls, latest error."
|
||||
|
||||
fmt:
|
||||
countTotal: "%d total"
|
||||
dockerContainers: "%d running / %d total"
|
||||
coreLoad: "%d core · load %.2f"
|
||||
ramUsage: "%3d%% "
|
||||
listMore: "… %d more (press 1)"
|
||||
phase: "Phase %s"
|
||||
|
||||
duration:
|
||||
days: "%d days %d hours"
|
||||
hours: "%d hours %d mins"
|
||||
minutes: "%d mins"
|
||||
|
||||
doctor:
|
||||
dockerDaemonUnreachable: "daemon not responding: %v"
|
||||
dockerOk: "engine %s (API %s) · %d/%d containers running · %d images"
|
||||
stacksMissing: "%s (missing — created during onboarding)"
|
||||
|
||||
cli:
|
||||
unknownCommand: "unknown command: %q"
|
||||
|
||||
usage:
|
||||
usage: "Usage:"
|
||||
open: "Open the interactive TUI"
|
||||
doctor: "Check system health (docker, audio, stacks)"
|
||||
version: "Show version & build info"
|
||||
more: "Other CLI subcommands (convert, ps, up, down, update) arriving in Phase 2–3 — see docs/PRD.md §F-028."
|
||||
@@ -0,0 +1,96 @@
|
||||
# Katalog Bahasa Indonesia (default) — F-023
|
||||
# Aturan: istilah teknis (Env, Image, Volume, Stack, Shell, CPU, RAM, PID, Log,
|
||||
# Docker, API) tidak diterjemahkan.
|
||||
|
||||
nav:
|
||||
home: "beranda"
|
||||
containers: "kontainer"
|
||||
|
||||
section:
|
||||
clock: "Jam"
|
||||
host: "Host"
|
||||
containers: "Kontainer"
|
||||
activity: "Aktivitas"
|
||||
detail: "Detail"
|
||||
|
||||
host:
|
||||
local: "lokal"
|
||||
|
||||
field:
|
||||
id: "ID"
|
||||
image: "Image"
|
||||
state: "State"
|
||||
status: "Status"
|
||||
host: "Host"
|
||||
cpu: "CPU"
|
||||
ram: "RAM"
|
||||
uptime: "Uptime"
|
||||
engine: "Engine"
|
||||
api: "API"
|
||||
|
||||
state:
|
||||
loading: "memuat…"
|
||||
noContainers: "belum ada kontainer"
|
||||
connected: "● terhubung"
|
||||
running: "berjalan"
|
||||
exited: "berhenti"
|
||||
paused: "jeda"
|
||||
restarting: "memulai ulang"
|
||||
created: "dibuat"
|
||||
dead: "mati"
|
||||
|
||||
error:
|
||||
fail: "GAGAL"
|
||||
warn: "PERINGATAN"
|
||||
ok: "OK"
|
||||
loadFailed: "GAGAL memuat"
|
||||
loadContainers: "Gagal memuat daftar kontainer"
|
||||
|
||||
hint:
|
||||
retry: "tekan r untuk coba lagi"
|
||||
noContainers: "Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."
|
||||
miniNoContainers: "belum ada kontainer — tekan 1"
|
||||
tabsComing: "Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."
|
||||
footerHome: "[1/enter] kontainer · [t] toast · [a] reduced-motion · [q] keluar"
|
||||
footerContainers: "[j/k] pilih · [r] muat ulang · [0/esc] beranda · [q] keluar"
|
||||
|
||||
footer:
|
||||
status: "%s animasi %s fps · audio: %s"
|
||||
|
||||
toast:
|
||||
demoTitle: "✔ [ SUKSES ] Konversi Berhasil Disimpan"
|
||||
demoBody: "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/"
|
||||
dockerError: "✖ [ GAGAL ] Docker"
|
||||
|
||||
activity:
|
||||
coming: "Listener Docker events menyusul (Phase 2)."
|
||||
willShow: "Panel ini akan menampilkan 5 aktivitas terbaru:"
|
||||
examples: "kontainer start/stop, image di-pull, error terakhir."
|
||||
|
||||
fmt:
|
||||
countTotal: "%d total"
|
||||
dockerContainers: "%d jalan / %d total"
|
||||
coreLoad: "%d core · load %.2f"
|
||||
ramUsage: "%3d%% "
|
||||
listMore: "… %d lainnya (tekan 1)"
|
||||
phase: "Phase %s"
|
||||
|
||||
duration:
|
||||
days: "%d hari %d jam"
|
||||
hours: "%d jam %d mnt"
|
||||
minutes: "%d mnt"
|
||||
|
||||
doctor:
|
||||
dockerDaemonUnreachable: "daemon tidak merespons: %v"
|
||||
dockerOk: "engine %s (API %s) · %d/%d kontainer berjalan · %d image"
|
||||
stacksMissing: "%s (belum ada — dibuat saat onboarding)"
|
||||
|
||||
cli:
|
||||
unknownCommand: "perintah tidak dikenal: %q"
|
||||
|
||||
usage:
|
||||
usage: "Pemakaian:"
|
||||
open: "Buka TUI interaktif"
|
||||
doctor: "Periksa kesehatan sistem (docker, audio, stack)"
|
||||
version: "Tampilkan versi & build info"
|
||||
more: "Subcommand CLI lain (convert, ps, up, down, update) menyusul di Phase 2–3 — lihat docs/PRD.md §F-028."
|
||||
Reference in new issue
Block a user