435 lines
12 KiB
Go
435 lines
12 KiB
Go
package i18n
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
type entry struct {
|
|
schema map[string]Kind
|
|
patterns map[string]*compiled
|
|
}
|
|
|
|
// Bundle owns detached, compiled resources and is safe for concurrent reads.
|
|
// Reconfiguration constructs another Bundle; no global current language exists.
|
|
type Bundle struct {
|
|
config Config
|
|
matcher language.Matcher
|
|
locales []string
|
|
tags map[string]language.Tag
|
|
entries map[Ref]*entry
|
|
documents []Document
|
|
}
|
|
|
|
func sortedKeys[V any](m map[string]V) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
func validID(s string) bool {
|
|
return s != "" && len(s) <= 256 && utf8.ValidString(s) && strings.TrimSpace(s) == s && strings.IndexFunc(s, unicode.IsControl) < 0
|
|
}
|
|
func canonical(s string) (string, error) {
|
|
if s == "" || len(s) > 128 {
|
|
return "", fmt.Errorf("i18n: language tag required (maximum 128 bytes)")
|
|
}
|
|
t, err := language.Parse(s)
|
|
if err != nil || t == language.Und {
|
|
return "", fmt.Errorf("i18n: invalid language tag")
|
|
}
|
|
return t.String(), nil
|
|
}
|
|
|
|
func normalizeConfig(c Config) (Config, error) {
|
|
var err error
|
|
if c.SourceLocale, err = canonical(c.SourceLocale); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if c.DefaultLocale, err = canonical(c.DefaultLocale); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if len(c.Locales) == 0 || len(c.Locales) > 64 {
|
|
return Config{}, fmt.Errorf("i18n: enable between 1 and 64 locales")
|
|
}
|
|
seen := map[string]bool{}
|
|
locales := make([]string, 0, len(c.Locales))
|
|
for _, raw := range c.Locales {
|
|
l, e := canonical(raw)
|
|
if e != nil {
|
|
return Config{}, e
|
|
}
|
|
if seen[l] {
|
|
return Config{}, fmt.Errorf("i18n: duplicate normalized locale %s", l)
|
|
}
|
|
seen[l] = true
|
|
locales = append(locales, l)
|
|
}
|
|
if !seen[c.DefaultLocale] {
|
|
return Config{}, fmt.Errorf("i18n: default locale must be enabled")
|
|
}
|
|
c.Locales = locales
|
|
fallbacks := map[string][]string{}
|
|
for _, raw := range sortedKeys(c.Fallbacks) {
|
|
l, e := canonical(raw)
|
|
if e != nil || !seen[l] {
|
|
return Config{}, fmt.Errorf("i18n: fallback locale must be enabled")
|
|
}
|
|
if _, ok := fallbacks[l]; ok {
|
|
return Config{}, fmt.Errorf("i18n: duplicate normalized fallback locale")
|
|
}
|
|
if len(c.Fallbacks[raw]) > 64 {
|
|
return Config{}, fmt.Errorf("i18n: too many fallback locales")
|
|
}
|
|
list := []string{}
|
|
unique := map[string]bool{}
|
|
for _, v := range c.Fallbacks[raw] {
|
|
to, e := canonical(v)
|
|
if e != nil || !seen[to] || unique[to] {
|
|
return Config{}, fmt.Errorf("i18n: fallback targets must be distinct enabled locales")
|
|
}
|
|
unique[to] = true
|
|
list = append(list, to)
|
|
}
|
|
fallbacks[l] = list
|
|
}
|
|
state := map[string]uint8{}
|
|
var visit func(string) error
|
|
visit = func(l string) error {
|
|
if state[l] == 1 {
|
|
return fmt.Errorf("i18n: fallback cycle")
|
|
}
|
|
if state[l] == 2 {
|
|
return nil
|
|
}
|
|
state[l] = 1
|
|
for _, to := range fallbacks[l] {
|
|
if err := visit(to); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
state[l] = 2
|
|
return nil
|
|
}
|
|
for _, l := range locales {
|
|
if err := visit(l); err != nil {
|
|
return Config{}, err
|
|
}
|
|
}
|
|
c.Fallbacks = fallbacks
|
|
return c, nil
|
|
}
|
|
|
|
func (c Config) Validate() error { _, err := normalizeConfig(c); return err }
|
|
|
|
// New accepts split documents; duplicate (locale, namespace, key) definitions
|
|
// are rejected. Use Overlay explicitly when replacement is intended.
|
|
func New(config Config, documents ...Document) (*Bundle, error) {
|
|
c, err := normalizeConfig(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b := &Bundle{config: c, tags: map[string]language.Tag{}, entries: map[Ref]*entry{}}
|
|
b.locales = append([]string{c.DefaultLocale}, c.Locales...)
|
|
b.locales = unique(b.locales)
|
|
tags := make([]language.Tag, 0, len(b.locales))
|
|
for _, l := range b.locales {
|
|
t := language.MustParse(l)
|
|
tags = append(tags, t)
|
|
b.tags[l] = t
|
|
}
|
|
b.matcher = language.NewMatcher(tags)
|
|
if len(documents) > 1024 {
|
|
return nil, fmt.Errorf("i18n: too many resource documents")
|
|
}
|
|
groups := map[string]map[string]map[string]Message{}
|
|
total := 0
|
|
names := map[string]bool{}
|
|
languages := map[string]bool{}
|
|
for _, doc := range documents {
|
|
l, e := canonical(doc.Locale)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
if doc.Version != Version || !validID(doc.Namespace) {
|
|
return nil, fmt.Errorf("i18n: version 1 and a valid namespace are required")
|
|
}
|
|
names[doc.Namespace] = true
|
|
languages[l] = true
|
|
if len(names) > 128 || len(languages) > 64 {
|
|
return nil, fmt.Errorf("i18n: namespace or locale resource limit exceeded")
|
|
}
|
|
if groups[l] == nil {
|
|
groups[l] = map[string]map[string]Message{}
|
|
}
|
|
if groups[l][doc.Namespace] == nil {
|
|
groups[l][doc.Namespace] = map[string]Message{}
|
|
}
|
|
for _, k := range sortedKeys(doc.Messages) {
|
|
if !validID(k) {
|
|
return nil, fmt.Errorf("i18n: invalid message key")
|
|
}
|
|
if _, ok := groups[l][doc.Namespace][k]; ok {
|
|
return nil, &Diagnostic{Kind: "duplicate", Namespace: doc.Namespace, Key: k, Locale: l, Problem: "use an explicit overlay to replace a message"}
|
|
}
|
|
total++
|
|
if total > 10000 {
|
|
return nil, fmt.Errorf("i18n: resource exceeds 10000 messages")
|
|
}
|
|
groups[l][doc.Namespace][k] = doc.Messages[k]
|
|
}
|
|
}
|
|
for _, ns := range sortedKeys(groups[c.SourceLocale]) {
|
|
for _, key := range sortedKeys(groups[c.SourceLocale][ns]) {
|
|
m := groups[c.SourceLocale][ns][key]
|
|
if len(m.Params) > 64 {
|
|
return nil, fmt.Errorf("i18n: message exceeds 64 parameters")
|
|
}
|
|
schema := map[string]Kind{}
|
|
for name, kind := range m.Params {
|
|
if !paramName.MatchString(name) || len(name) > 128 {
|
|
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Param: name, Problem: "invalid parameter name"}
|
|
}
|
|
switch kind {
|
|
case String, Boolean, Integer, Number:
|
|
default:
|
|
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Param: name, Problem: "unsupported type"}
|
|
}
|
|
schema[name] = kind
|
|
}
|
|
b.entries[Ref{ns, key}] = &entry{schema: schema, patterns: map[string]*compiled{}}
|
|
}
|
|
}
|
|
limits := &budget{}
|
|
for _, l := range sortedKeys(groups) {
|
|
for _, ns := range sortedKeys(groups[l]) {
|
|
doc := Document{Version: Version, Locale: l, Namespace: ns, Messages: map[string]Message{}}
|
|
for _, key := range sortedKeys(groups[l][ns]) {
|
|
m := groups[l][ns][key]
|
|
e := b.entries[Ref{ns, key}]
|
|
if e == nil {
|
|
return nil, &Diagnostic{Kind: "unknown", Namespace: ns, Key: key, Locale: l, Problem: "message has no source definition"}
|
|
}
|
|
if m.Params != nil && !sameSchema(m.Params, e.schema) {
|
|
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Locale: l, Problem: "translation parameter schema differs from source"}
|
|
}
|
|
used := map[string]bool{}
|
|
p, err := compile(m.Value, e.schema, used, limits, 0)
|
|
if err == nil {
|
|
for _, name := range sortedKeys(e.schema) {
|
|
if !used[name] {
|
|
err = &Diagnostic{Kind: "parameter", Param: name, Problem: "declared parameter is unused in this message"}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
d := *err.(*Diagnostic)
|
|
d.Namespace, d.Key, d.Locale = ns, key, l
|
|
return nil, &d
|
|
}
|
|
e.patterns[l] = p
|
|
doc.Messages[key] = Message{Params: cloneSchema(e.schema), Value: clonePattern(m.Value)}
|
|
}
|
|
b.documents = append(b.documents, doc)
|
|
}
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func unique(in []string) []string {
|
|
out := []string{}
|
|
seen := map[string]bool{}
|
|
for _, s := range in {
|
|
if !seen[s] {
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func sameSchema(a, b map[string]Kind) bool {
|
|
return len(a) == len(b) && reflect.DeepEqual(cloneSchema(a), cloneSchema(b))
|
|
}
|
|
func cloneSchema(m map[string]Kind) map[string]Kind {
|
|
out := map[string]Kind{}
|
|
for k, v := range m {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
func clonePattern(p Pattern) Pattern {
|
|
if p.Cases != nil {
|
|
m := make(map[string]Pattern, len(p.Cases))
|
|
for k, v := range p.Cases {
|
|
m[k] = clonePattern(v)
|
|
}
|
|
p.Cases = m
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (b *Bundle) Config() Config {
|
|
if b == nil {
|
|
return Config{}
|
|
}
|
|
c := b.config
|
|
c.Locales = append([]string(nil), c.Locales...)
|
|
c.Fallbacks = map[string][]string{}
|
|
for k, v := range b.config.Fallbacks {
|
|
c.Fallbacks[k] = append([]string{}, v...)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Match accepts ordered language tags, not an HTTP header. It returns an enabled
|
|
// dictionary tag (not a synthetic matcher tag with user extensions).
|
|
func (b *Bundle) Match(preferences ...string) string {
|
|
if b == nil {
|
|
return ""
|
|
}
|
|
tags := make([]language.Tag, 0, len(preferences))
|
|
for _, s := range preferences {
|
|
if len(tags) >= 64 {
|
|
break
|
|
}
|
|
if len(s) > 128 {
|
|
continue
|
|
}
|
|
if t, e := language.Parse(s); s != "" && e == nil {
|
|
tags = append(tags, t)
|
|
}
|
|
}
|
|
_, index, _ := b.matcher.Match(tags...)
|
|
return b.locales[index]
|
|
}
|
|
|
|
func (b *Bundle) chain(locale string) []string {
|
|
out := []string{}
|
|
seen := map[string]bool{}
|
|
var add func(string)
|
|
add = func(l string) {
|
|
if seen[l] {
|
|
return
|
|
}
|
|
seen[l] = true
|
|
out = append(out, l)
|
|
for _, to := range b.config.Fallbacks[l] {
|
|
add(to)
|
|
}
|
|
}
|
|
add(locale)
|
|
add(b.config.DefaultLocale)
|
|
add(b.config.SourceLocale)
|
|
return out
|
|
}
|
|
|
|
func (b *Bundle) Render(locale string, ref Ref, params map[string]any) (Result, error) {
|
|
if b == nil {
|
|
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Problem: "no bundle"}
|
|
}
|
|
requested := b.Match(locale)
|
|
e := b.entries[ref]
|
|
if e == nil {
|
|
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Locale: requested, Problem: "unknown message"}
|
|
}
|
|
values := make(map[string]string, len(e.schema))
|
|
for _, k := range sortedKeys(e.schema) {
|
|
v, ok := params[k]
|
|
if !ok {
|
|
return Result{}, &Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Param: k, Problem: "missing argument"}
|
|
}
|
|
s, err := scalar(e.schema[k], v)
|
|
if err != nil {
|
|
d := *err.(*Diagnostic)
|
|
d.Namespace, d.Key, d.Param = ref.Namespace, ref.Key, k
|
|
return Result{}, &d
|
|
}
|
|
values[k] = s
|
|
}
|
|
for _, k := range sortedKeys(params) {
|
|
if _, ok := e.schema[k]; !ok {
|
|
return Result{}, &Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Param: k, Problem: "undeclared argument"}
|
|
}
|
|
}
|
|
for _, l := range b.chain(requested) {
|
|
if p := e.patterns[l]; p != nil {
|
|
text, err := p.render(language.MustParse(l), values)
|
|
return Result{Text: text, Requested: requested, Locale: l, Fallback: l != requested}, err
|
|
}
|
|
}
|
|
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Locale: requested, Problem: "no message in fallback chain"}
|
|
}
|
|
func (b *Bundle) RenderContext(ctx context.Context, ref Ref, params map[string]any) (Result, error) {
|
|
return b.Render(Locale(ctx), ref, params)
|
|
}
|
|
|
|
// Schema returns a detached source schema for adapters and static tools.
|
|
func (b *Bundle) Schema(ref Ref) (map[string]Kind, bool) {
|
|
if b == nil {
|
|
return nil, false
|
|
}
|
|
e := b.entries[ref]
|
|
if e == nil {
|
|
return nil, false
|
|
}
|
|
return cloneSchema(e.schema), true
|
|
}
|
|
|
|
// Check reports missing direct translations. Runtime fallback remains usable;
|
|
// build tooling can reject these diagnostics without changing runtime behavior.
|
|
func (b *Bundle) Check() []Diagnostic {
|
|
out := []Diagnostic{}
|
|
if b == nil {
|
|
return out
|
|
}
|
|
for _, d := range b.documents {
|
|
if d.Locale != b.config.SourceLocale {
|
|
continue
|
|
}
|
|
for _, key := range sortedKeys(d.Messages) {
|
|
e := b.entries[Ref{d.Namespace, key}]
|
|
for _, l := range b.config.Locales {
|
|
if e.patterns[l] == nil {
|
|
out = append(out, Diagnostic{Kind: "missing", Namespace: d.Namespace, Key: key, Locale: l, Problem: "direct translation missing"})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Documents returns detached resources. An empty namespace filter includes all;
|
|
// exporting for a browser should normally supply explicit namespaces.
|
|
func (b *Bundle) Documents(locale string, namespaces ...string) []Document {
|
|
out := []Document{}
|
|
if b == nil {
|
|
return out
|
|
}
|
|
wanted := map[string]bool{}
|
|
for _, ns := range namespaces {
|
|
wanted[ns] = true
|
|
}
|
|
for _, d := range b.documents {
|
|
if locale != "" && d.Locale != locale || len(wanted) > 0 && !wanted[d.Namespace] {
|
|
continue
|
|
}
|
|
copy := d
|
|
copy.Messages = map[string]Message{}
|
|
for k, m := range d.Messages {
|
|
copy.Messages[k] = Message{Params: cloneSchema(m.Params), Value: clonePattern(m.Value)}
|
|
}
|
|
out = append(out, copy)
|
|
}
|
|
return out
|
|
}
|