Internationalization
Text you pass to a component is yours. The few strings a component writes itself are i18n.T messages, and one line in gsx.toml routes all of them through your translator.
Get started
Add ui/i18n/translate.go beside the vendored ui/i18n/i18n.go and register it, using your module path:
[renderers]
"example.com/app/ui/i18n.T" = "example.com/app/ui/i18n.Translate"
Write the translator. It receives the render context, so the request's locale is in reach:
package i18n
import "context"
// Translate renders every T. The English text is the message id; an
// unknown one renders as English. Replace lookup with your catalog.
func Translate(ctx context.Context, m T) string {
if s, ok := lookup(ctx, string(m)); ok {
return s
}
return string(m)
}
How it works
i18n.Tis a string type inui/i18n/i18n.go, its own package, vendored with any component that uses it.- The English text is the message id:
i18n.T("Close"),i18n.T("Next slide"),i18n.T("Toggle Sidebar"). - Without a renderer, a message renders as its English text.
- With one, gsx calls your function everywhere a message renders, in text and in attributes.
- Renderers bind when
gsx generateruns in your module, so only vendored components translate. Importinggithub.com/gsxhq/gsxui/uidirectly renders English. - Children, props and
attrsnever pass throughT. gsxui add --overwriterewrites only the files it vendored, sotranslate.gosurvives.
Calendar
Calendar composes month and weekday names, the caption and each day's label. Pass a locale; the zero value is English.
CaptionandDayLabelsubstitute{month},{year},{weekday}and{day}. Other text is literal.Digitsis ten runes replacing 0-9 in visible text. Form values and data attributes stay ASCII.MonthsFormatis the month inside a date, for day labels: Russian29 июня, captionиюнь. Leave it unset where both forms match.- Fill every other field. An empty field renders empty; only the all-zero value means English.
- The client reads the same values from the root, so navigation writes the same text the server did.
| س | ح | ن | ث | ر | خ | ج |
|---|---|---|---|---|---|---|
package calendar
import (
"time"
"github.com/gsxhq/gsxui/site/uirtl"
)
// LocalizedDefaultMonth mirrors Basic's own DefaultMonth (2026-01).
var LocalizedDefaultMonth = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// Arabic is CLDR's ar gregorian data: wide month names (the abbreviated set
// is identical), wide and narrow weekday names, and Arabic-Indic digits.
// The day label puts the day before the month, and the week starts on
// Saturday.
var Arabic = uirtl.CalendarLocale{
Months: [12]string{
"يناير",
"فبراير",
"مارس",
"أبريل",
"مايو",
"يونيو",
"يوليو",
"أغسطس",
"سبتمبر",
"أكتوبر",
"نوفمبر",
"ديسمبر",
},
MonthsShort: [12]string{
"يناير",
"فبراير",
"مارس",
"أبريل",
"مايو",
"يونيو",
"يوليو",
"أغسطس",
"سبتمبر",
"أكتوبر",
"نوفمبر",
"ديسمبر",
},
Weekdays: [7]string{"الأحد", "الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت"},
WeekdaysShort: [7]string{"ح", "ن", "ث", "ر", "خ", "ج", "س"},
Caption: "{month} {year}",
DayLabel: "{weekday}، {day} {month} {year}",
Digits: "٠١٢٣٤٥٦٧٨٩",
}
// Localized renders the dropdown-caption grid in Arabic under dir="rtl".
// The month is a parameter so the harness's ?month= override can render any
// month for the Go/JS agreement diff.
component Localized(month time.Time) {
<div dir="rtl" lang="ar">
<uirtl.Calendar
mode="single"
month={month}
weekStartsOn={time.Saturday}
showOutsideDays={true}
captionLayout="dropdown"
locale={Arabic}
/>
</div>
}
Filling the locale from CLDR
Every field is CLDR data, so the server can fill it from a CLDR source instead of a typed table. With Intl on the server (a Cloudflare Worker has V8's), each field is one call; every date format also takes calendar: "gregory", timeZone: "UTC":
Months: themonthpart offormatToPartsfor{month: "long", year: "numeric"}, the caption's own form: Russianиюнь, Japanese6(its月is inCaption).MonthsFormat: themonthpart for the day label's{weekday: "long", month: "long", day: "numeric", year: "numeric"}: Russianиюня.MonthsShort:{month: "short"}.WeekdaysandWeekdaysShort:{weekday: "long"}and{weekday: "narrow"}on seven days from a Sunday. Narrow names can repeat (RussianВ П В С Ч П С); type the header row by hand where they do.CaptionandDayLabel: the same two formats' parts, with eachmonth,year,weekdayanddaypart replaced by its placeholder. Russian's caption is{month} {year} г.Digits:new Intl.NumberFormat(lang, {useGrouping: false}).format(1234567890)with its last digit moved to the front; empty when that is ASCII. Plainargives ASCII;ar-EGorar-u-nu-arabgives٠١٢٣٤٥٦٧٨٩.weekStartsOn, aCalendarargument:new Intl.Locale(lang).maximize().getWeekInfo().firstDay % 7.
Finding leftover English
Register a translator that wraps every message in markers, render each page, and search the output for English outside the markers. Anything found is text a caller passed in, or a gsxui bug worth an issue.
Not translated
When no ui.Toaster is mounted, toaster.js creates its own region with an English aria-label. Mount ui.Toaster and the landmark is a message like the rest.