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 }