feat: release v0.26 portable i18n and presentation adapters
This commit is contained in:
434
i18n/bundle.go
Normal file
434
i18n/bundle.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user