Documentation
¶
Overview ¶
Package translatable stores multilingual fields in an application's own tables and resolves them with locale fallback.
A translated field is a jsonb column holding one string per locale:
CREATE TABLE products (
id bigint PRIMARY KEY,
title jsonb NOT NULL DEFAULT '{}'
);
In Go the column is a Text, which implements json.Marshaler, json.Unmarshaler, sql.Scanner and driver.Valuer, so it can be scanned from and bound to queries with pgx or database/sql without conversion. The package defines no tables of its own.
Locales are BCP 47 tags from golang.org/x/text/language. Keys are always stored in canonical form (en-us becomes en-US, iw becomes he), and invalid or undetermined tags, and tags longer than MaxLocaleLen bytes, are rejected with ErrInvalidLocale. A Text holds at most MaxLocales translations.
Errors ¶
Every error caused by invalid input, here and in the subpackages, matches ErrInvalid with errors.Is; the more specific sentinels (ErrInvalidLocale, ErrDuplicateLocale, ErrValidation) wrap it. Decoding never panics on corrupt input.
Fallback ¶
A Fallback decides which locales to try when a translation is missing: the requested locale, an explicit chain for it (pt-BR, then pt, then en) or its CLDR parents (en-GB, then en-001, then en; zh-Hans-CN, then zh-Hans, then zh; zh-Hant-TW, then zh-Hant only), then the default locales, and optionally any translation at all. The order is deterministic and chains cannot loop. Text.Resolve applies it and reports which locale the value came from; Text.ResolveContext reads the requested locale from a context through the FallbackConfig.LocaleFrom hook, which defaults to LocaleFrom and so to the value stored by WithLocale.
Subpackages ¶
Package httplocale negotiates a request's locale from a query parameter, a cookie, Accept-Language and an application-supplied context value, with bounded parsing of untrusted input, and stores it with WithLocale. Package pgsql builds SQL expressions that apply the same fallback inside queries, for selecting, sorting and filtering, plus index DDL.
Validation ¶
Rules checks that required locales are present and that translations respect a maximum length, returning FieldErrors that name the field and locale of each problem.
Index ¶
- Constants
- Variables
- func Key(tag language.Tag) string
- func LocaleFrom(ctx context.Context) (language.Tag, bool)
- func ParseLocale(s string) (language.Tag, error)
- func WithLocale(ctx context.Context, tag language.Tag) context.Context
- type Fallback
- type FallbackConfig
- type FieldError
- type FieldErrors
- type Rules
- type Text
- func (t Text) Clone() Text
- func (t *Text) Delete(tag language.Tag)
- func (t Text) Get(tag language.Tag) (string, bool)
- func (t Text) Has(tag language.Tag) bool
- func (t Text) IsEmpty() bool
- func (t Text) Len() int
- func (t Text) Locales() []language.Tag
- func (t Text) Map() map[string]string
- func (t Text) MarshalJSON() ([]byte, error)
- func (t Text) Resolve(want language.Tag, f *Fallback) (string, language.Tag, bool)
- func (t Text) ResolveContext(ctx context.Context, f *Fallback) (string, language.Tag, bool)
- func (t *Text) Scan(src any) error
- func (t *Text) Set(tag language.Tag, value string) error
- func (t *Text) UnmarshalJSON(data []byte) error
- func (t Text) Value() (driver.Value, error)
Examples ¶
Constants ¶
const ( CodeRequired = "required" CodeTooLong = "too_long" )
Codes reported in FieldError.Code. They are stable strings, suitable for API responses and for looking up user-facing messages.
const MaxLocaleLen = 64
MaxLocaleLen is the longest locale string, in bytes, that ParseLocale accepts, and the longest canonical key a Text, Fallback or Negotiator accepts. It is generous for real tags (RFC 5646 section 4.4.1 sizes buffers at 35 characters) and bounds the cost of parsing untrusted input, since language.Parse is superlinear in the number of variant subtags.
const MaxLocales = 1024
MaxLocales is the most locales one Text holds, and the most keys NewText, UnmarshalJSON and Scan accept, counting keys whose value is empty or null. It is above the number of locales CLDR defines, and it bounds the per-key parsing work that untrusted input can demand.
Variables ¶
var ErrDuplicateLocale error = &kindError{msg: "translatable: duplicate locale", kind: ErrInvalid}
ErrDuplicateLocale is returned, wrapped, when input names the same locale twice, either with the same key or with different spellings such as "en-us" and "en-US". Picking one silently would lose a translation, so decoding fails instead. It matches ErrInvalid.
var ErrInvalid = errors.New("translatable: invalid input")
ErrInvalid is matched by errors.Is for every error this module returns because its input failed validation: malformed or undetermined locales, duplicate locales, oversized input, rule violations and invalid configuration, in this package and in httplocale and pgsql. Applications can map it to a single "bad request" response.
var ErrInvalidLocale error = &kindError{msg: "translatable: invalid locale", kind: ErrInvalid}
ErrInvalidLocale is returned, wrapped, when a string is not a well-formed and known BCP 47 language tag, is longer than MaxLocaleLen bytes, or names no concrete language (such as "und", "und-US" or "x-private"). It matches ErrInvalid.
var ErrValidation error = &kindError{msg: "translatable: validation failed", kind: ErrInvalid}
ErrValidation is matched by errors.Is for every error returned by Rules.Validate, so callers can tell input problems from other failures. It matches ErrInvalid.
Functions ¶
func Key ¶
Key returns the canonical string under which translations for tag are stored, both in a Text and in the jsonb column it is persisted to. Tags built with language.Raw are canonicalized first, so every way of naming a locale maps to one key. The undetermined tag yields "und".
func LocaleFrom ¶
LocaleFrom returns the locale stored in ctx by WithLocale. The boolean is false when ctx carries no locale or carries the undetermined tag.
func ParseLocale ¶
ParseLocale parses s as a BCP 47 language tag and returns it in canonical form, so "en-us" and "EN_us" both yield en-US. It returns an error wrapping ErrInvalidLocale when s is longer than MaxLocaleLen bytes, is not a well-formed and known tag, or names no concrete language: "und" and tags such as "und-US" cannot identify a translation.
Types ¶
type Fallback ¶
type Fallback struct {
// contains filtered or unexported fields
}
Fallback decides which locales to try, and in which order, when the requested translation is missing. Build one with NewFallback. A Fallback is immutable and safe for concurrent use. A nil *Fallback behaves like one built from a zero FallbackConfig.
func MustFallback ¶
func MustFallback(cfg FallbackConfig) *Fallback
MustFallback is like NewFallback but panics if NewFallback returns an error. It is meant for package-level variables built from literals, where an invalid locale is a programmer error; never pass it configuration read at run time.
func NewFallback ¶
func NewFallback(cfg FallbackConfig) (*Fallback, error)
NewFallback validates cfg and returns a Fallback. Every locale in Defaults and Chains must be a concrete, valid BCP 47 tag of at most MaxLocaleLen bytes; errors wrap ErrInvalidLocale, or ErrDuplicateLocale when two Chains keys name the same locale. All of them match ErrInvalid.
func (*Fallback) AnyAvailable ¶
AnyAvailable reports whether resolution falls back to any present translation once the chain is exhausted. It is false for a nil f.
func (*Fallback) Chain ¶
Chain returns the locales to try for want, in order and without duplicates: want itself, then either its explicit chain or its parent tags (an explicit chain on a parent takes over from there), then the defaults. The result depends only on f and want, never on map iteration order. When want is undetermined only the defaults are returned. The "any available" last resort is not part of the chain because it depends on the data; see AnyAvailable.
Example ¶
package main
import (
"fmt"
"golang.org/x/text/language"
translatable "github.com/byteminus/go-translatable"
)
func main() {
fb := translatable.MustFallback(translatable.FallbackConfig{
Defaults: []language.Tag{language.English},
})
fmt.Println(fb.Chain(language.MustParse("es-MX")))
}
Output: [es-MX es-419 es en]
func (*Fallback) ChainKeys ¶
ChainKeys is Chain expressed as canonical keys, which is what the SQL builders bind as parameters.
func (*Fallback) Defaults ¶
Defaults returns the configured default locales, in order. It is handy for requiring the defaults in validation Rules. A nil f has none.
func (*Fallback) LocaleFrom ¶ added in v0.2.0
LocaleFrom returns the requested locale in ctx using the configured FallbackConfig.LocaleFrom hook, or the package-level LocaleFrom when the hook is nil or f is nil. Use it to obtain the want argument for Chain and the pgsql builders, so they see the same locale as Text.ResolveContext.
type FallbackConfig ¶ added in v0.2.0
type FallbackConfig struct {
// Defaults are tried, in order, after the requested locale and its
// chain. They are usually the application's default locale.
Defaults []language.Tag
// Chains maps a locale to an explicit list of locales to try after it,
// for example {"pt-BR": {"pt", "en"}}. Keys and values are BCP 47
// strings so the map can come straight from configuration. When the
// requested locale, or one of its parents, has an explicit chain, that
// chain replaces the remaining automatic parent tags. A chain is used as
// written and never expanded through other chains, so chains that refer
// to each other cannot loop.
Chains map[string][]string
// SkipParents disables automatic parent tags, so en-GB no longer falls
// back to en. Parents follow CLDR inheritance (see language.Tag.Parent),
// which keeps non-default scripts apart from the bare language:
// zh-Hant-TW and zh-TW reach zh-Hant and stop, never zh (Simplified);
// sr-Latn-RS reaches sr-Latn and stops, never sr (Cyrillic). A default
// script does reach the bare language, and is kept before it is dropped:
// zh-Hans-CN tries zh-Hans, then zh; sr-Cyrl tries sr.
SkipParents bool
// AnyAvailable makes resolution fall back, as a last resort, to any
// translation present, choosing the lowest canonical key so the result is
// stable. It suits display contexts where some text beats none.
AnyAvailable bool
// LocaleFrom returns the requested locale for an operation, as used by
// Text.ResolveContext and Fallback.LocaleFrom. Nil means the
// package-level LocaleFrom, which reads the value stored by WithLocale.
// It must be safe for concurrent use.
LocaleFrom func(ctx context.Context) (language.Tag, bool)
}
FallbackConfig configures a Fallback. The zero value is valid: it tries the requested locale and then its parent tags, and reads the requested locale from the context with LocaleFrom.
Example (LocaleFrom) ¶
An application that already tracks the user's language in its own context value wires it once into the Fallback; every ResolveContext then uses it.
package main
import (
"context"
"fmt"
"golang.org/x/text/language"
translatable "github.com/byteminus/go-translatable"
)
func main() {
type userLang struct{}
fb := translatable.MustFallback(translatable.FallbackConfig{
Defaults: []language.Tag{language.English},
LocaleFrom: func(ctx context.Context) (language.Tag, bool) {
tag, ok := ctx.Value(userLang{}).(language.Tag)
return tag, ok
},
})
greeting := translatable.MustText(map[string]string{"en": "Hello", "pt": "Ola"})
ctx := context.WithValue(context.Background(), userLang{}, language.MustParse("pt-BR"))
v, from, _ := greeting.ResolveContext(ctx, fb)
fmt.Println(v, from)
}
Output: Ola pt
type FieldError ¶
type FieldError struct {
// Field is the name the caller passed to Rules.Validate.
Field string
// Locale is the locale the problem concerns.
Locale language.Tag
// Code is CodeRequired or CodeTooLong.
Code string
// Max is the length limit for CodeTooLong, and zero otherwise.
Max int
}
FieldError describes one problem with one locale of a translated field.
func (*FieldError) Path ¶
func (e *FieldError) Path() string
Path returns the field and locale joined with a dot, such as "title.en", the form most API error formats use for nested fields.
func (*FieldError) Unwrap ¶
func (e *FieldError) Unwrap() error
Unwrap makes every FieldError match ErrValidation, and so ErrInvalid.
type FieldErrors ¶
type FieldErrors []*FieldError
FieldErrors is the error returned by Rules.Validate when at least one rule fails. Use errors.As to retrieve it and inspect each FieldError.
func (FieldErrors) Error ¶
func (es FieldErrors) Error() string
Error implements error, joining every problem on one line.
func (FieldErrors) Unwrap ¶
func (es FieldErrors) Unwrap() []error
Unwrap exposes each FieldError to errors.Is and errors.As.
type Rules ¶
type Rules struct {
// Required lists locales that must have a translation. A common choice
// is Fallback.Defaults, so resolution can always succeed.
Required []language.Tag
// MaxLength, when positive, caps every translation at this many
// characters, counted as Unicode code points to match Postgres
// varchar(n) and char_length.
MaxLength int
}
Rules are validation constraints for a Text. The zero value accepts everything. Rules holds no state, so one value may be used from several goroutines as long as Required is not modified meanwhile.
func (Rules) Validate ¶
Validate checks t against r and returns nil or a FieldErrors naming field. Missing required locales are reported first, in the order given and once per locale, then over-long translations in canonical locale order, so the output is deterministic. An undetermined tag in Required is always reported missing, since no Text can hold it.
Example ¶
package main
import (
"errors"
"fmt"
"golang.org/x/text/language"
translatable "github.com/byteminus/go-translatable"
)
func main() {
rules := translatable.Rules{Required: []language.Tag{language.English}, MaxLength: 10}
title := translatable.MustText(map[string]string{"de": "Ein sehr langer Titel"})
err := rules.Validate("title", title)
var fes translatable.FieldErrors
if errors.As(err, &fes) {
for _, fe := range fes {
fmt.Println(fe.Path(), fe.Code)
}
}
}
Output: title.en required title.de too_long
type Text ¶
type Text struct {
// contains filtered or unexported fields
}
Text holds the translations of one string field, keyed by locale. It is stored as a jsonb object such as {"en": "Hello", "pt-BR": "Ola"}.
Keys are always canonical BCP 47 tags (see Key), and a Text never holds an empty string: setting a locale to "" removes it, so "translated as empty" and "not translated" cannot diverge between Go and SQL. A Text holds at most MaxLocales translations. Translation values are not limited in size; bound untrusted input before decoding it (for example with http.MaxBytesReader) and check lengths with Rules.
The zero value is an empty Text ready to use. Like a map, a Text has reference semantics: copies share their translations until one of them is first written from empty, so use Clone for an independent copy. A Text is not safe for concurrent mutation; concurrent reads are safe.
Example ¶
package main
import (
"encoding/json"
"fmt"
"golang.org/x/text/language"
translatable "github.com/byteminus/go-translatable"
)
func main() {
var title translatable.Text
_ = title.Set(language.MustParse("en-us"), "Color")
_ = title.Set(language.German, "Farbe")
v, ok := title.Get(language.AmericanEnglish)
fmt.Println(v, ok)
fmt.Println(title.Locales())
b, _ := json.Marshal(title)
fmt.Println(string(b))
}
Output: Color true [de en-US] {"de":"Farbe","en-US":"Color"}
func MustText ¶
MustText is like NewText but panics if NewText returns an error. It is meant for literals in tests and package-level variables, where an invalid locale is a programmer error; never pass it untrusted input.
func NewText ¶
NewText builds a Text from translations keyed by locale strings, which are parsed with ParseLocale. Empty values are skipped. It returns an error wrapping ErrInvalidLocale for an invalid key, ErrDuplicateLocale when two keys name the same locale, or ErrInvalid when values has more than MaxLocales keys or a value that is not valid UTF-8. A nil or empty map yields an empty Text.
func (Text) Get ¶
Get returns the translation for exactly tag, with no fallback. The boolean is false when there is none.
func (Text) Locales ¶
Locales returns the locales that have a translation, sorted by their canonical key so output is stable across runs.
func (Text) Map ¶
Map returns a copy of the translations keyed by canonical locale string, for handing to templates or encoders that expect a plain map. It is never nil.
func (Text) MarshalJSON ¶
MarshalJSON encodes t as a JSON object keyed by canonical locale. An empty Text encodes as {} rather than null, matching the NOT NULL DEFAULT '{}' columns it is designed for. It does not fail for any Text.
func (Text) Resolve ¶
Resolve returns the first translation found along f's fallback chain for want (see Fallback.Chain), together with the locale it came from. A nil f behaves like a zero Fallback. The boolean is false when no candidate has a translation; the returned locale is then the undetermined tag.
Example ¶
package main
import (
"fmt"
"golang.org/x/text/language"
translatable "github.com/byteminus/go-translatable"
)
func main() {
fb := translatable.MustFallback(translatable.FallbackConfig{
Defaults: []language.Tag{language.English},
Chains: map[string][]string{"pt-BR": {"pt", "en"}},
})
title := translatable.MustText(map[string]string{"en": "Chair", "pt": "Cadeira"})
for _, want := range []string{"pt-BR", "en-GB", "ja"} {
v, from, _ := title.Resolve(language.MustParse(want), fb)
fmt.Printf("%s: %s (from %s)\n", want, v, from)
}
}
Output: pt-BR: Cadeira (from pt) en-GB: Chair (from en) ja: Chair (from en)
func (Text) ResolveContext ¶
ResolveContext is Resolve with the locale that f's LocaleFrom hook finds in ctx (see FallbackConfig.LocaleFrom); with a nil f or a nil hook that is the locale stored by WithLocale. When ctx carries no locale, only f's defaults and last resort apply.
Example ¶
package main
import (
"context"
"fmt"
"golang.org/x/text/language"
translatable "github.com/byteminus/go-translatable"
)
func main() {
fb := translatable.MustFallback(translatable.FallbackConfig{Defaults: []language.Tag{language.English}})
greeting := translatable.MustText(map[string]string{"en": "Hello", "de": "Hallo"})
ctx := translatable.WithLocale(context.Background(), language.MustParse("de-AT"))
v, _, _ := greeting.ResolveContext(ctx, fb)
fmt.Println(v)
}
Output: Hallo
func (*Text) Scan ¶
Scan implements sql.Scanner for json and jsonb columns, accepting []byte and string sources as UnmarshalJSON does. SQL NULL scans as an empty Text. Other source types, and corrupt or oversized content, return an error matching ErrInvalid. On error t is left unchanged.
func (*Text) Set ¶
Set stores value as the translation for tag. An empty value removes the translation. It returns an error wrapping ErrInvalidLocale when tag names no concrete language or its key is longer than MaxLocaleLen, and one matching ErrInvalid when value is not valid UTF-8 or t already holds MaxLocales other translations; t is unchanged on error.
func (*Text) UnmarshalJSON ¶
UnmarshalJSON decodes a JSON object of locale to string. Keys are canonicalized; JSON null, either for the whole value or for one locale, means "no translation". It returns an error matching ErrInvalid for malformed JSON, a value that is not an object, a non-string value, and trailing data, and the errors described at NewText for keys. Input is decoded as a stream, so it stops at the first problem, including the key after the MaxLocales-th. On error t is left unchanged.
Example ¶
package main
import (
"encoding/json"
"errors"
"fmt"
translatable "github.com/byteminus/go-translatable"
)
func main() {
var title translatable.Text
err := json.Unmarshal([]byte(`{"en-us": "Color", "de": null}`), &title)
fmt.Println(title.Map(), err)
err = json.Unmarshal([]byte(`{"en": "Color", "EN": "Colour"}`), &title)
fmt.Println(errors.Is(err, translatable.ErrDuplicateLocale), errors.Is(err, translatable.ErrInvalid))
}
Output: map[en-US:Color] <nil> true true
Directories
¶
| Path | Synopsis |
|---|---|
|
Package httplocale negotiates the locale of an HTTP request against the set of locales an application supports, and stores the result in the request context with translatable.WithLocale, where Text.ResolveContext and the SQL builders' callers can pick it up.
|
Package httplocale negotiates the locale of an HTTP request against the set of locales an application supports, and stores the result in the request context with translatable.WithLocale, where Text.ResolveContext and the SQL builders' callers can pick it up. |
|
internal
|
|
|
pgtest
Package pgtest gives integration tests an isolated PostgreSQL schema.
|
Package pgtest gives integration tests an isolated PostgreSQL schema. |
|
Package pgsql builds PostgreSQL expressions for reading, sorting and filtering translatable.Text values stored in jsonb columns, following a translatable.Fallback chain inside the query.
|
Package pgsql builds PostgreSQL expressions for reading, sorting and filtering translatable.Text values stored in jsonb columns, following a translatable.Fallback chain inside the query. |