feat: release v0.26 portable i18n and presentation adapters
This commit is contained in:
143
i18n/load.go
Normal file
143
i18n/load.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// DecodeJSON rejects unknown fields, duplicate keys and excessive nesting.
|
||||
// It never expands environment references or evaluates message text.
|
||||
func DecodeJSON(data []byte, dst any) error {
|
||||
if len(data) > 1<<20 || !utf8.Valid(data) {
|
||||
return fmt.Errorf("i18n: document exceeds 1 MiB or is not UTF-8")
|
||||
}
|
||||
scan := json.NewDecoder(bytes.NewReader(data))
|
||||
scan.UseNumber()
|
||||
nodes := 0
|
||||
var walk func(int) error
|
||||
walk = func(depth int) error {
|
||||
nodes++
|
||||
if depth > 32 || nodes > 100000 {
|
||||
return fmt.Errorf("i18n: JSON nesting or node limit exceeded")
|
||||
}
|
||||
t, err := scan.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("i18n: invalid JSON")
|
||||
}
|
||||
d, ok := t.(json.Delim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch d {
|
||||
case '{':
|
||||
seen := map[string]bool{}
|
||||
for scan.More() {
|
||||
k, e := scan.Token()
|
||||
if e != nil {
|
||||
return fmt.Errorf("i18n: invalid JSON object")
|
||||
}
|
||||
key, ok := k.(string)
|
||||
if !ok || seen[key] {
|
||||
return fmt.Errorf("i18n: duplicate JSON key")
|
||||
}
|
||||
seen[key] = true
|
||||
if err := walk(depth + 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
for scan.More() {
|
||||
if err := walk(depth + 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("i18n: invalid JSON delimiter")
|
||||
}
|
||||
_, err = scan.Token()
|
||||
return err
|
||||
}
|
||||
if err := walk(0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := scan.Token(); err != io.EOF {
|
||||
return fmt.Errorf("i18n: expected one JSON value")
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.UseNumber()
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(dst); err != nil {
|
||||
return fmt.Errorf("i18n: invalid document schema")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFS reads exactly the explicitly listed files; it never scans directories.
|
||||
func LoadFS(source fs.FS, paths ...string) ([]Document, error) {
|
||||
if source == nil || len(paths) > 1024 {
|
||||
return nil, fmt.Errorf("i18n: filesystem required; at most 1024 files")
|
||||
}
|
||||
docs := make([]Document, 0, len(paths))
|
||||
total := 0
|
||||
for _, path := range paths {
|
||||
if !fs.ValidPath(path) {
|
||||
return nil, fmt.Errorf("i18n: invalid resource path")
|
||||
}
|
||||
f, err := source.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("i18n: cannot open %s: %w", path, err)
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("i18n: resource must be a regular file: %s", path)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total += len(data)
|
||||
if total > 4<<20 {
|
||||
return nil, fmt.Errorf("i18n: resources exceed 4 MiB")
|
||||
}
|
||||
var doc Document
|
||||
if err := DecodeJSON(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
// Overlay replaces whole messages, never individual branches or parameter
|
||||
// schemas. The returned document is detached and must still pass New validation.
|
||||
func Overlay(base, override Document) (Document, error) {
|
||||
l1, e1 := canonical(base.Locale)
|
||||
l2, e2 := canonical(override.Locale)
|
||||
if e1 != nil || e2 != nil || l1 != l2 || base.Namespace != override.Namespace || base.Version != Version || override.Version != Version {
|
||||
return Document{}, fmt.Errorf("i18n: overlay requires matching version, locale and namespace")
|
||||
}
|
||||
// A bounded JSON round trip also rejects cyclic Go values before cloning.
|
||||
data, err := json.Marshal([]Document{base, override})
|
||||
if err != nil || len(data) > 1<<20 {
|
||||
return Document{}, fmt.Errorf("i18n: invalid or oversized overlay")
|
||||
}
|
||||
var docs []Document
|
||||
if err := DecodeJSON(data, &docs); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
out := docs[0]
|
||||
out.Locale = l1
|
||||
if out.Messages == nil {
|
||||
out.Messages = map[string]Message{}
|
||||
}
|
||||
for key, m := range docs[1].Messages {
|
||||
out.Messages[key] = m
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user