feat: release v0.26 portable i18n and presentation adapters

This commit is contained in:
2026-09-29 13:31:42 +08:00
parent 6a95cad184
commit d1a747c36f
75 changed files with 5681 additions and 52 deletions

434
i18n/bundle.go Normal file
View File

@@ -0,0 +1,434 @@
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
}