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.T is a string type in ui/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 generate runs in your module, so only vendored components translate. Importing github.com/gsxhq/gsxui/ui directly renders English.
  • Children, props and attrs never pass through T.
  • gsxui add --overwrite rewrites only the files it vendored, so translate.go survives.

Calendar

Calendar composes month and weekday names, the caption and each day's label. Pass a locale; the zero value is English.

  • Caption and DayLabel substitute {month}, {year}, {weekday} and {day}. Other text is literal.
  • Digits is ten runes replacing 0-9 in visible text. Form values and data attributes stay ASCII.
  • MonthsFormat is the month inside a date, for day labels: Russian 29 июня, 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: the month part of formatToParts for {month: "long", year: "numeric"}, the caption's own form: Russian июнь, Japanese 6 (its 月 is in Caption).
  • MonthsFormat: the month part for the day label's {weekday: "long", month: "long", day: "numeric", year: "numeric"}: Russian июня.
  • MonthsShort: {month: "short"}.
  • Weekdays and WeekdaysShort: {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.
  • Caption and DayLabel: the same two formats' parts, with each month, year, weekday and day part 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. Plain ar gives ASCII; ar-EG or ar-u-nu-arab gives ٠١٢٣٤٥٦٧٨٩.
  • weekStartsOn, a Calendar argument: 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.