多语言翻译
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package langx
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// codeFileName 是响应码文件名
|
||||
const codeFileName = "code.json"
|
||||
|
||||
// RegisterDir 从磁盘目录加载响应码与译文, 等价于对 os.DirFS(dir) 调用 RegisterFS。
|
||||
//
|
||||
// 资源文件的组织方式:
|
||||
// - 根目录下的 <语言>.json (如 zh.json、en-US.json) 是该语言的译文;
|
||||
// - 根目录下的 code.json 是响应码, 内容为 map[string]int;
|
||||
// - 子目录的第一段是语言名, 例如 zh/common.json、zh/errors.json 都会并入 zh(按键合并);
|
||||
// - 非 .json 文件直接忽略; 任何文件解析失败都会返回错误, 并且不会改变已加载的数据。
|
||||
//
|
||||
// 如果目录下没有 .json 文件且只有一个子目录(常见于 langs/、i18n/ 这类容器目录),
|
||||
// 会自动向内继续查找(最多 8 层), 因此 RegisterDir("assets") 也能装载 assets/langs/zh.json。
|
||||
func RegisterDir(dir string) error {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return fmt.Errorf("langx: 目录不能为空")
|
||||
}
|
||||
return RegisterFS(os.DirFS(dir))
|
||||
}
|
||||
|
||||
// RegisterFS 与 RegisterDir 规则相同, 但资源来自任意 fs.FS。
|
||||
//
|
||||
// 已加载的数据是按键合并的: 多次调用会累加, 同一个 key 以最后一次为准。
|
||||
func RegisterFS(fsys fs.FS) error {
|
||||
if fsys == nil {
|
||||
return fmt.Errorf("langx: fs.FS 不能为 nil")
|
||||
}
|
||||
data, err := loadFS(fsys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return updateErr(func(old *state) (*state, error) {
|
||||
next := setCodes(old, data.code, true)
|
||||
for lang, trans := range data.trans {
|
||||
next = setTrans(next, lang, trans, true)
|
||||
}
|
||||
return next, nil
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterEmbed 从 embed.FS 加载, 规则与 RegisterFS 完全一致。
|
||||
func RegisterEmbed(asset embed.FS) error {
|
||||
return RegisterFS(asset)
|
||||
}
|
||||
|
||||
// loadedData 是一次加载的结果, 只有全部解析成功才会写入快照
|
||||
type loadedData struct {
|
||||
code map[string]int
|
||||
trans map[string]map[string]string
|
||||
}
|
||||
|
||||
func loadFS(fsys fs.FS) (*loadedData, error) {
|
||||
root, err := resolveRoot(fsys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("langx: 读取根目录失败: %w", err)
|
||||
}
|
||||
|
||||
data := &loadedData{
|
||||
code: map[string]int{},
|
||||
trans: map[string]map[string]string{},
|
||||
}
|
||||
|
||||
err = fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !isJSONName(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
rel := relativePath(root, p)
|
||||
raw, err := fs.ReadFile(fsys, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("langx: 读取 %s 失败: %w", p, err)
|
||||
}
|
||||
// 根目录下的 code.json 是响应码; 更深层的 code.json 视为该语言的普通文件
|
||||
if !strings.Contains(rel, "/") && strings.EqualFold(rel, codeFileName) {
|
||||
var codes map[string]int
|
||||
if err := json.Unmarshal(raw, &codes); err != nil {
|
||||
return fmt.Errorf("langx: 解析响应码文件 %s 失败: %w", p, err)
|
||||
}
|
||||
for k, v := range codes {
|
||||
data.code[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lang := langOfRelativePath(rel)
|
||||
if lang == "" {
|
||||
return nil
|
||||
}
|
||||
var trans map[string]string
|
||||
if err := json.Unmarshal(raw, &trans); err != nil {
|
||||
return fmt.Errorf("langx: 解析语言文件 %s 失败: %w", p, err)
|
||||
}
|
||||
dst := data.trans[lang]
|
||||
if dst == nil {
|
||||
dst = map[string]string{}
|
||||
data.trans[lang] = dst
|
||||
}
|
||||
for k, v := range trans {
|
||||
dst[k] = v
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// resolveRoot 跳过纯粹的容器目录: 当前目录没有 .json 文件且只有一个子目录时继续向内
|
||||
func resolveRoot(fsys fs.FS) (string, error) {
|
||||
root := "."
|
||||
for i := 0; i < 8; i++ {
|
||||
entries, err := fs.ReadDir(fsys, root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hasJSON, subDir, dirs := false, "", 0
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
dirs++
|
||||
subDir = e.Name()
|
||||
continue
|
||||
}
|
||||
if isJSONName(e.Name()) {
|
||||
hasJSON = true
|
||||
}
|
||||
}
|
||||
if hasJSON || dirs != 1 {
|
||||
return root, nil
|
||||
}
|
||||
if root == "." {
|
||||
root = subDir
|
||||
} else {
|
||||
root = root + "/" + subDir
|
||||
}
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// relativePath 返回 p 相对于 root 的路径
|
||||
func relativePath(root string, p string) string {
|
||||
if root == "." {
|
||||
return p
|
||||
}
|
||||
return strings.TrimPrefix(strings.TrimPrefix(p, root), "/")
|
||||
}
|
||||
|
||||
// langOfRelativePath 推导语言名: 顶层文件取文件名, 子目录取第一段路径
|
||||
func langOfRelativePath(rel string) string {
|
||||
if i := strings.IndexByte(rel, '/'); i >= 0 {
|
||||
return normalizeLang(rel[:i])
|
||||
}
|
||||
return normalizeLang(strings.TrimSuffix(rel, path.Ext(rel)))
|
||||
}
|
||||
|
||||
// isJSONName 判断文件名是否为 .json(不区分大小写)
|
||||
func isJSONName(name string) bool {
|
||||
return strings.EqualFold(path.Ext(name), ".json")
|
||||
}
|
||||
Reference in New Issue
Block a user