translatable

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 13 Imported by: 0

README

go-translatable

tests Go Reference

Multilingual database fields and locale fallback for Go SaaS applications. Translations live in your own tables as jsonb columns, so there are no extra tables, joins or migrations to own. translatable.Text scans from and binds to those columns with pgx or database/sql; a Fallback decides what to show when a translation is missing (pt-BR to pt to en, en-GB to en); httplocale picks the request locale from the URL, a cookie or Accept-Language; and pgsql applies the same fallback inside SQL for selecting, sorting and filtering, with every locale and value bound as a parameter.

Install

go get github.com/byteminus/go-translatable

Requires Go 1.26 or later.

Quick start

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/jackc/pgx/v5/pgxpool"
	"golang.org/x/text/language"

	translatable "github.com/byteminus/go-translatable"
	"github.com/byteminus/go-translatable/httplocale"
	"github.com/byteminus/go-translatable/pgsql"
)

var fallback = translatable.MustFallback(translatable.FallbackConfig{
	Defaults: []language.Tag{language.English},
	Chains:   map[string][]string{"pt-BR": {"pt", "en"}},
})

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	// Write: a Text is an ordinary query argument.
	title := translatable.MustText(map[string]string{"en": "Chair", "pt": "Cadeira"})
	if err := (translatable.Rules{Required: fallback.Defaults(), MaxLength: 120}).Validate("title", title); err != nil {
		log.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO products (id, title) VALUES ($1, $2)`, 1, title); err != nil {
		log.Fatal(err)
	}

	negotiator, err := httplocale.New([]language.Tag{language.English, language.MustParse("pt-BR")}, httplocale.Options{})
	if err != nil {
		log.Fatal(err)
	}

	http.Handle("/products/1", negotiator.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Read in Go: scan the whole column, resolve with the request locale.
		var t translatable.Text
		if err := pool.QueryRow(r.Context(), `SELECT title FROM products WHERE id = 1`).Scan(&t); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		value, from, _ := t.ResolveContext(r.Context(), fallback)
		fmt.Fprintf(w, "%s (%s)\n", value, from)
	})))

	http.Handle("/products", negotiator.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Read in SQL: resolve, filter and sort inside the query.
		want, _ := httplocale.FromRequest(r)
		col := pgsql.MustColumn("p", "title")
		var args pgsql.Args
		q := `SELECT p.id, ` + col.Value(&args, fallback, want) + ` FROM products p` +
			` WHERE ` + col.Contains(&args, fallback, want, r.URL.Query().Get("q")) +
			` ORDER BY ` + col.SortKey(&args, fallback, want, "")
		rows, err := pool.Query(r.Context(), q, args...)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		defer rows.Close()
		for rows.Next() {
			var id int
			var name string
			if err := rows.Scan(&id, &name); err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			fmt.Fprintf(w, "%d %s\n", id, name)
		}
	})))

	log.Fatal(http.ListenAndServe(":8080", nil))
}

Concepts

Locales are BCP 47 tags (golang.org/x/text/language). Every key is stored canonically: en-us becomes en-US, iw becomes he. ParseLocale rejects malformed and unknown tags, tags with no concrete language (und, und-US, x-private) and input longer than MaxLocaleLen (64) bytes with ErrInvalidLocale; input that names one locale twice ({"en-us": .., "en-US": ..}, or the same key repeated) fails with ErrDuplicateLocale instead of silently dropping a translation.

Errors. Every error caused by invalid input, in all three packages, matches translatable.ErrInvalid with errors.Is, so one check maps them all to a 400 response. The specific sentinels (ErrInvalidLocale, ErrDuplicateLocale, ErrValidation, httplocale.ErrNoSupported, pgsql.ErrInvalidIdentifier) wrap it.

Text holds one string per locale. Get, Has, Set, Delete, Locales (sorted), Len, IsEmpty, Clone and Map work on exact locales. A Text never stores empty strings: setting "" removes the locale, and empty or null values are dropped when decoding, so "translated as empty" and "not translated" mean the same thing in Go and in SQL. The zero value encodes as {}. Like a map, a Text has reference semantics; use Clone for an independent copy.

A Text holds at most MaxLocales (1024) translations, and NewText, UnmarshalJSON and Scan stop at the first key past that limit, so a hostile document cannot demand unbounded locale parsing. Decoding streams the JSON, never panics on corrupt input (it is fuzzed), and leaves the Text unchanged on error. Translation values are not size-limited: bound request bodies (for example with http.MaxBytesReader) and use Rules.MaxLength.

Fallback turns a requested locale into an ordered list of locales to try (Chain):

  1. the requested locale;
  2. its explicit chain from Chains, or else its CLDR parents (en-GB, en-001, en; es-MX, es-419, es). An explicit chain on a parent takes over from that parent, and is used as written, never expanded through other chains, so chains cannot loop. Parents follow CLDR: a non-default script never falls back to the bare language, since zh content is Simplified and sr content is Cyrillic. zh-Hant-TW and zh-TW try zh-Hant and stop; sr-Latn-RS tries sr-Latn and stops. A default script does reach it: zh-Hans-CN tries zh-Hans, then zh (the script tag is not skipped, although CLDR jumps straight to zh); sr-Cyrl tries sr. SkipParents turns parents off;
  3. the Defaults, in order;
  4. with AnyAvailable, any translation present, lowest key first.

The chain depends only on the configuration and the requested locale, so it is the same on every call and in SQL.

Text.Resolve(want, fb) returns the value and the locale it came from. WithLocale and LocaleFrom carry the request locale in a context, and Text.ResolveContext(ctx, fb) resolves with it, reading the locale through FallbackConfig.LocaleFrom (see below). A nil *Fallback behaves like the zero configuration. A Fallback is immutable and safe for concurrent use.

Request locale. httplocale.Negotiator reads candidates from the query parameter lang, the cookie lang and Accept-Language (order and names configurable, plus an optional Context source), matches each against the supported locales with language.Matcher, and returns the supported tag itself (en-GB is served by en, pt-PT by pt-BR). The first source with a match wins; the first supported locale is the default. Its middleware stores the result with WithLocale and adds Vary: Accept-Language and Vary: Cookie for the sources it is configured with, without repeating values already present.

All request input is treated as hostile:

  • The query parameter and cookie must be a single BCP 47 tag of letters, digits and hyphens of at most 64 bytes (pt_BR is rejected), matching a supported locale with high confidence; sr is not served sr-Latn.
  • Accept-Language is read up to MaxAcceptLanguageLen (4096) bytes and MaxAcceptLanguageTags (32) members. Weights must be RFC 9110 qvalues (0 to 1, three decimals at most); a member with q=NaN, q=2 or any other malformed weight is dropped, and one bad member no longer discards the rest of the header. golang.org/x/text itself only caps the number of hyphens at 1000.
  • The stored locale is always one of the supported tags, and nothing from the request is written to a response header.

The Negotiator never sets the cookie. If you remember a visitor's choice, write the negotiated tag (never the raw request value) with Path=/, SameSite=Lax, Secure on HTTPS and a Max-Age such as one year; the value is not secret, so HttpOnly is optional, and it is re-validated on every request.

Wiring the locale once. Every component that reads the locale from a context has a LocaleFrom hook with the signature of translatable.LocaleFrom; nil means that function. An application that already knows the user's language sets it once at startup:

package main

import (
	"context"
	"log"
	"net/http"

	"golang.org/x/text/language"

	translatable "github.com/byteminus/go-translatable"
	"github.com/byteminus/go-translatable/httplocale"
)

// userKey stands in for however your application stores the signed-in user.
type userKey struct{}

type user struct{ Locale string }

func userLocale(ctx context.Context) (language.Tag, bool) {
	u, ok := ctx.Value(userKey{}).(user)
	if !ok || u.Locale == "" {
		return language.Und, false
	}
	tag, err := translatable.ParseLocale(u.Locale)
	return tag, err == nil
}

func main() {
	fallback, err := translatable.NewFallback(translatable.FallbackConfig{
		Defaults:   []language.Tag{language.English},
		LocaleFrom: userLocale, // used by Text.ResolveContext and Fallback.LocaleFrom
	})
	if err != nil {
		log.Fatal(err)
	}
	negotiator, err := httplocale.New([]language.Tag{language.English, language.German}, httplocale.Options{
		// The user's saved preference beats the cookie and the browser, but
		// an explicit ?lang= still wins.
		Sources:    []httplocale.Source{httplocale.Query, httplocale.Context, httplocale.Cookie, httplocale.Header},
		LocaleFrom: userLocale,
	})
	if err != nil {
		log.Fatal(err)
	}
	greeting := translatable.MustText(map[string]string{"en": "Hello", "de": "Hallo"})
	http.Handle("/", negotiator.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		v, _, _ := greeting.ResolveContext(r.Context(), fallback)
		_, _ = w.Write([]byte(v))
	})))
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Fallback.LocaleFrom(ctx) gives the same locale for the want argument of Chain and the pgsql builders.

Validation. Rules{Required, MaxLength}.Validate(field, text) returns FieldErrors, one FieldError per problem with Field, Locale, Code (required, too_long), Max and a Path() such as title.pt-BR. All of them match ErrValidation with errors.Is. Length is counted in Unicode code points, as Postgres char_length and varchar(n) do.

PostgreSQL

This package has no tables of its own. A translated field is a column in your table:

CREATE TABLE products (
    id    bigint PRIMARY KEY,
    title jsonb  NOT NULL DEFAULT '{}'
);

Text implements sql.Scanner and driver.Valuer, and is tested both with pgx v5 directly and through database/sql (pgx stdlib). SQL NULL scans as an empty Text.

Querying

Package pgsql builds expressions over such a column. A Column is built once from identifier parts (quoted with pgx.Identifier), and each builder appends its bind parameters to an Args, which hands out $n placeholders, so fragments compose with each other and with your own parameters. Args is a []any, so pass it as args....

Builder SQL shape
Get(&a, tag) NULLIF(col->>$1::text, '')
Value(&a, fb, want) COALESCE(NULLIF(col->>$1::text, ''), NULLIF(col->>$2::text, ''), ...), plus an any-available subquery when configured
Locale(&a, fb, want) CASE WHEN ... THEN $1::text ... END: the locale Value came from
SortKey(&a, fb, want, collation) Value, optionally COLLATE "de-x-icu"
Has(&a, tag) (COALESCE(col->>$1::text, '') <> ''), never NULL
Equals(&a, tag, value) (col @> jsonb_build_object($1::text, $2::text))
Contains(&a, fb, want, s) (Value ILIKE $n::text ESCAPE '\') with % and _ escaped

The integration suite checks that Value and Locale agree with Text.Resolve for every row, locale and fallback configuration it covers. Locales and values are always bind parameters; nothing user-supplied is interpolated. Identifier parts are quoted and rejected when empty, longer than MaxIdentifierLen (63) bytes, or containing NUL, since PostgreSQL would silently truncate them and pgx would strip the NUL, either of which could make a name refer to a different column or index. The test suite runs every builder with hostile identifiers, values and locale strings against a real database.

Indexes

Index DDL cannot take parameters, so LocaleIndex and GINIndex generate it from canonical tags (letters, digits and hyphens only) and quoted identifiers:

ddl, err := pgsql.LocaleIndex("products_title_en", []string{"products"}, "title", language.English)
// CREATE INDEX IF NOT EXISTS "products_title_en" ON "products" ((NULLIF("title"->>'en', '')))

ddl, err = pgsql.GINIndex("products_title_gin", []string{"products"}, "title")
// CREATE INDEX IF NOT EXISTS "products_title_gin" ON "products" USING gin ("title" jsonb_path_ops)

The per-locale expression index serves Get(...) = $n and sorting by one exact locale. Because the locale is a bind parameter, the planner can match it only in a plan made for the bound value: PostgreSQL's default plan_cache_mode = auto keeps such custom plans for repeatedly executed prepared statements (pgx caches them) as long as they are cheaper, which an index scan is, but force_generic_plan prevents the match. The GIN index serves Equals for every locale under either setting. Both behaviours are covered by tests. Fallback expressions (Value, Contains) are evaluated per row; for large tables, narrow the rows with an indexed predicate first.

Transactions

There is no store and therefore no WithConn option: the builders produce SQL and arguments, and you run them on whatever you already hold, a *pgxpool.Pool, a *pgx.Conn or a pgx.Tx, so writes join your transaction naturally.

API overview

Package translatable:

  • ParseLocale, Key, MaxLocaleLen, ErrInvalid, ErrInvalidLocale, ErrDuplicateLocale
  • Text, NewText, MustText, MaxLocales; methods Get, Set, Delete, Has, Locales, Len, IsEmpty, Clone, Map, Resolve, ResolveContext, plus JSON and SQL interfaces
  • Fallback, FallbackConfig, NewFallback, MustFallback; methods Chain, ChainKeys, Defaults, AnyAvailable, LocaleFrom
  • WithLocale, LocaleFrom
  • Rules, FieldError, FieldErrors, ErrValidation, CodeRequired, CodeTooLong

Package httplocale: New(supported, Options), Options, Negotiator (Negotiate, Middleware), Source (Query, Cookie, Header, Context), FromRequest, DefaultQueryParam, DefaultCookieName, MaxAcceptLanguageLen, MaxAcceptLanguageTags, ErrNoSupported.

Package pgsql: Args, Column, NewColumn, MustColumn, the builders above, LocaleIndex, GINIndex, MaxIdentifierLen, ErrInvalidIdentifier.

The Must* helpers panic on invalid input and exist for literals in package-level variables and tests; never pass them run-time data.

Testing

go test -race ./...
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable go test -race ./...

Without TEST_DATABASE_URL the PostgreSQL tests skip, unless CI is set, in which case they fail. Fuzz targets cover every parser of outside input:

go test -run='^$' -fuzz='^FuzzParseLocale$' -fuzztime=60s .
go test -run='^$' -fuzz='^FuzzNewText$' -fuzztime=60s .
go test -run='^$' -fuzz='^FuzzTextUnmarshalJSON$' -fuzztime=60s .
go test -run='^$' -fuzz='^FuzzTextScan$' -fuzztime=60s .
go test -run='^$' -fuzz='^FuzzNegotiateAcceptLanguage$' -fuzztime=60s ./httplocale
go test -run='^$' -fuzz='^FuzzNegotiateExplicit$' -fuzztime=60s ./httplocale
``` Each test works in its own randomly named schema,
dropped afterwards, so the suite is safe to run in parallel against a shared
database.

## Roadmap

- A generic `Value[T]` for structured translated content (for example
  translated JSON objects). It was left out of v0.1.0 because the semantics
  that make `Text` predictable do not carry over cleanly: there is no
  generic notion of "empty" to treat as missing, `Clone` could only be
  shallow, and the SQL side needs `->` with jsonb results rather than
  `->>` with text.
- An `Allowed` rule that rejects locales outside the supported set.
- Full-text search helpers (per-locale `tsvector` with the matching text
  search configuration).
- Writing the negotiated locale back to a cookie from the middleware.
- Partial-update helpers (`jsonb_set` for one locale) for concurrent editors
  working on different languages of the same field.

## Stability

This package follows [semantic versioning](https://semver.org). Before
v1.0.0, a minor release may contain breaking changes; each one is listed in
[CHANGELOG.md](CHANGELOG.md) with upgrade steps, including SQL for schema
changes. From v1.0.0, the exported API and the PostgreSQL schema change only
in backwards-compatible ways until the next major version.

## License

MIT. See [LICENSE](LICENSE).

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

Examples

Constants

View Source
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.

View Source
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.

View Source
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

View Source
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.

View Source
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.

View Source
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.

View Source
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

func Key(tag language.Tag) string

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

func LocaleFrom(ctx context.Context) (language.Tag, bool)

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

func ParseLocale(s string) (language.Tag, error)

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.

func WithLocale

func WithLocale(ctx context.Context, tag language.Tag) context.Context

WithLocale returns a copy of ctx that carries tag as the request locale. Storing the undetermined tag is equivalent to storing no locale.

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

func (f *Fallback) AnyAvailable() bool

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

func (f *Fallback) Chain(want language.Tag) []language.Tag

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

func (f *Fallback) ChainKeys(want language.Tag) []string

ChainKeys is Chain expressed as canonical keys, which is what the SQL builders bind as parameters.

func (*Fallback) Defaults

func (f *Fallback) Defaults() []language.Tag

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

func (f *Fallback) LocaleFrom(ctx context.Context) (language.Tag, bool)

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) Error

func (e *FieldError) Error() string

Error implements error.

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

func (r Rules) Validate(field string, t Text) error

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

func MustText(values map[string]string) Text

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

func NewText(values map[string]string) (Text, error)

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) Clone

func (t Text) Clone() Text

Clone returns an independent copy of t.

func (*Text) Delete

func (t *Text) Delete(tag language.Tag)

Delete removes the translation for tag, if any.

func (Text) Get

func (t Text) Get(tag language.Tag) (string, bool)

Get returns the translation for exactly tag, with no fallback. The boolean is false when there is none.

func (Text) Has

func (t Text) Has(tag language.Tag) bool

Has reports whether a translation exists for exactly tag.

func (Text) IsEmpty

func (t Text) IsEmpty() bool

IsEmpty reports whether t has no translations.

func (Text) Len

func (t Text) Len() int

Len returns the number of translations.

func (Text) Locales

func (t Text) Locales() []language.Tag

Locales returns the locales that have a translation, sorted by their canonical key so output is stable across runs.

func (Text) Map

func (t Text) Map() map[string]string

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

func (t Text) MarshalJSON() ([]byte, error)

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

func (t Text) Resolve(want language.Tag, f *Fallback) (string, language.Tag, bool)

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

func (t Text) ResolveContext(ctx context.Context, f *Fallback) (string, language.Tag, bool)

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

func (t *Text) Scan(src any) error

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

func (t *Text) Set(tag language.Tag, value string) error

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

func (t *Text) UnmarshalJSON(data []byte) error

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

func (Text) Value

func (t Text) Value() (driver.Value, error)

Value implements driver.Valuer. It returns the JSON encoding as a string, which every Postgres driver accepts for json and jsonb parameters; a []byte would be sent as bytea by some drivers. It does not fail for any Text.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL