diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9668e32 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# 统一使用 LF, 避免 gofmt 与 CRLF 互相拉锯 +* text=auto eol=lf + +# 二进制文件不做换行转换 +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.zip binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c0494b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: ci + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go: ["1.19.x", "stable"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "以下文件未格式化:" + echo "$unformatted" + exit 1 + fi + + - name: vet + run: go vet ./... + + - name: test + run: go test -race -count=1 -coverprofile=coverage.out ./... + + - name: build example + run: go build ./example diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a66b2b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# 测试与覆盖率产物 +coverage.out +cover.out +*.test + +# 编辑器/系统文件 +.idea/ +.vscode/ +.DS_Store diff --git a/common.go b/common.go index 9b0f83b..2c73343 100644 --- a/common.go +++ b/common.go @@ -1,35 +1,138 @@ package langx -import "context" +import ( + "context" + "sort" + "strconv" + "strings" +) -const ctxLangKey string = "ctxLang" +// ctxKey 使用私有类型做 context 的键, 避免与调用方使用的字符串键冲突 +type ctxKey struct{ name string } +var langCtxKey = ctxKey{"lang"} + +// acceptLanguageKey 是读取请求语言时使用的键。 +// 这里故意保留字符串 "Accept-Language", 以兼容把 HTTP 头直接放进 context 的既有用法。 +const acceptLanguageKey = "Accept-Language" + +// SetCtxLang 把语言写入 context, 其优先级高于 Accept-Language 请求头。 +// ctx 为 nil 时的行为与 context.WithValue 一致(panic)。 func SetCtxLang(ctx context.Context, lang string) context.Context { - return context.WithValue(ctx, ctxLangKey, lang) + return context.WithValue(ctx, langCtxKey, lang) } +// GetCtxLang 按 显式设置 > Accept-Language 协商 > 默认语言 的顺序返回语言。 +// +// 该函数不会 panic: ctx 为 nil、context 中的值类型不是 string/[]string 都会安全回退。 func GetCtxLang(ctx context.Context) string { - // 指定的 - lang := ctx.Value(ctxLangKey) - - l := "" - - if lang != nil { - l = lang.(string) + if ctx == nil { + return GetDefaultLang() } - - // HTTP头部的 - if l == "" { - lang = ctx.Value("Accept-Language") - if lang != nil { - l = lang.(string) + if v, ok := ctx.Value(langCtxKey).(string); ok { + if lang := strings.TrimSpace(v); lang != "" { + return lang } } - - // 默认的 - if l == "" { - l = GetDefaultLang() + // 兼容直接塞 HTTP 头的用法 + if header := headerFromCtx(ctx); header != "" { + if lang := NegotiateLang(header); lang != "" { + return lang + } } + return GetDefaultLang() +} - return l +// headerFromCtx 读取 context 中的 Accept-Language, 类型不匹配时返回空串 +func headerFromCtx(ctx context.Context) string { + switch v := ctx.Value(acceptLanguageKey).(type) { + case string: + return strings.TrimSpace(v) + case []string: + if len(v) > 0 { + return strings.TrimSpace(v[0]) + } + } + return "" +} + +// ParseAcceptLanguage 解析 Accept-Language 请求头, 按 q 值从高到低返回候选语言(已归一化为小写)。 +// +// 无 q 值的项按 1.0 处理; q=0 表示不接受, 会被丢弃; 通配符 "*" 原样保留, 由调用方决定如何处理。 +func ParseAcceptLanguage(header string) []string { + type candidate struct { + lang string + q float64 + } + candidates := make([]candidate, 0, 4) + for _, part := range strings.Split(header, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + lang, q := part, 1.0 + if i := strings.IndexByte(part, ';'); i >= 0 { + lang = strings.TrimSpace(part[:i]) + for _, param := range strings.Split(part[i+1:], ";") { + param = strings.TrimSpace(param) + if len(param) > 2 && (param[0] == 'q' || param[0] == 'Q') && param[1] == '=' { + if v, err := strconv.ParseFloat(strings.TrimSpace(param[2:]), 64); err == nil { + q = v + } + } + } + } + lang = normalizeLang(lang) + if lang == "" { + continue + } + if q <= 0 { // q=0 表示明确不接受该语言 + continue + } + candidates = append(candidates, candidate{lang: lang, q: q}) + } + sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].q > candidates[j].q }) + langs := make([]string, 0, len(candidates)) + for _, c := range candidates { + langs = append(langs, c.lang) + } + return langs +} + +// NegotiateLang 在已注册的语言中挑出与 Accept-Language 最匹配的一个; +// 匹配时先精确匹配, 再逐级去掉区域后缀(zh-CN -> zh); 没有匹配时返回空串。 +func NegotiateLang(header string) string { + s := load() + if len(s.trans) == 0 { + return "" + } + for _, lang := range ParseAcceptLanguage(header) { + if lang == "*" { + return "" + } + if hit, ok := matchLang(s, lang); ok { + return hit + } + } + return "" +} + +// matchLang 返回已注册语言中匹配 lang 的那一个: +// 先精确匹配, 再逐级去掉区域后缀, 最后按语言前缀匹配(请求 en-US 可以命中已注册的 en-GB)。 +func matchLang(s *state, lang string) (string, bool) { + l := normalizeLang(lang) + for l != "" { + if _, ok := s.trans[l]; ok { + return l, true + } + if best := bestPrefixLang(s.trans, l); best != "" { + return best, true + } + i := strings.LastIndexByte(l, '-') + if i <= 0 { + break + } + l = l[:i] + } + return "", false } diff --git a/common_test.go b/common_test.go new file mode 100644 index 0000000..d4f91a0 --- /dev/null +++ b/common_test.go @@ -0,0 +1,117 @@ +package langx_test + +import ( + "context" + "testing" + + "github.com/yuninks/langx" +) + +func TestGetCtxLang(t *testing.T) { + reset(t) + langx.RegisterTrans("zh", map[string]string{"k": "中文"}) + langx.RegisterTrans("en", map[string]string{"k": "English"}) + mustInit(t, langx.SetDefaultLanguage("zh")) + + // Accept-Language 会与已注册语言协商, 而不是原样返回 + ctx := context.WithValue(context.Background(), "Accept-Language", "en-US,en;q=0.9") + if got := langx.GetCtxLang(ctx); got != "en" { + t.Fatalf("GetCtxLang = %q, 期望 en", got) + } + if got := langx.GetMsgCtx(ctx, "k"); got != "English" { + t.Fatalf("GetMsgCtx = %q", got) + } + // []string 形式(http.Header 风格) + ctx = context.WithValue(context.Background(), "Accept-Language", []string{"en-GB"}) + if got := langx.GetMsgCtx(ctx, "k"); got != "English" { + t.Fatalf("[]string 形式 = %q", got) + } + // 空 []string + ctx = context.WithValue(context.Background(), "Accept-Language", []string{}) + if got := langx.GetCtxLang(ctx); got != "zh" { + t.Fatalf("空 []string = %q", got) + } + // 无法协商时回落默认语言 + ctx = context.WithValue(context.Background(), "Accept-Language", "fr-FR,de;q=0.8") + if got := langx.GetCtxLang(ctx); got != "zh" { + t.Fatalf("无法协商 = %q", got) + } + // 通配符 + ctx = context.WithValue(context.Background(), "Accept-Language", "*") + if got := langx.GetCtxLang(ctx); got != "zh" { + t.Fatalf("通配符 = %q", got) + } + // 显式设置的语言即使没注册也照用 + if got := langx.GetCtxLang(langx.SetCtxLang(context.Background(), "fr")); got != "fr" { + t.Fatalf("显式未注册语言 = %q", got) + } + // 空字符串的显式设置会被忽略 + if got := langx.GetCtxLang(langx.SetCtxLang(context.Background(), " ")); got != "zh" { + t.Fatalf("空显式语言 = %q", got) + } +} + +// 旧实现在这里会 panic, 现在必须安全回退 +func TestGetCtxLangNeverPanics(t *testing.T) { + reset(t) + langx.RegisterTrans("zh", map[string]string{"k": "中文"}) + mustInit(t, langx.SetDefaultLanguage("zh")) + + if got := langx.GetCtxLang(nil); got != "zh" { + t.Fatalf("nil ctx = %q", got) + } + if got := langx.GetMsgCtx(nil, "k"); got != "中文" { + t.Fatalf("nil ctx GetMsgCtx = %q", got) + } + for _, value := range []interface{}{123, []int{1}, struct{}{}, true} { + ctx := context.WithValue(context.Background(), "Accept-Language", value) + if got := langx.GetCtxLang(ctx); got != "zh" { + t.Fatalf("非法类型 %T = %q", value, got) + } + } +} + +func TestParseAcceptLanguage(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"zh-CN,zh;q=0.9,en;q=0.8", []string{"zh-cn", "zh", "en"}}, + {"en;q=0.5,zh;q=1.0", []string{"zh", "en"}}, + {"en", []string{"en"}}, + {" EN-US , zh ", []string{"en-us", "zh"}}, + {"*", []string{"*"}}, + {"de;q=0", nil}, + {"", nil}, + {" , ,, ", nil}, + {"garbage;;q=x,en", []string{"garbage", "en"}}, + {"zh;q=1;charset=utf-8,en;q=0.7", []string{"zh", "en"}}, + } + for _, c := range cases { + got := langx.ParseAcceptLanguage(c.in) + assertStrings(t, "ParseAcceptLanguage("+c.in+")", got, c.want) + } +} + +func TestNegotiateLang(t *testing.T) { + reset(t) + // 未注册任何语言时无法协商 + if got := langx.NegotiateLang("en"); got != "" { + t.Fatalf("无语言时 = %q", got) + } + langx.RegisterTrans("zh", map[string]string{"k": "中文"}) + langx.RegisterTrans("en-GB", map[string]string{"k": "English"}) + + if got := langx.NegotiateLang("en-GB,en;q=0.9"); got != "en-gb" { + t.Fatalf("精确匹配 = %q", got) + } + if got := langx.NegotiateLang("en-US,en;q=0.9"); got != "en-gb" { + t.Fatalf("区域回退 = %q", got) + } + if got := langx.NegotiateLang("fr,zh;q=0.3"); got != "zh" { + t.Fatalf("跳过不支持的语言 = %q", got) + } + if got := langx.NegotiateLang("fr"); got != "" { + t.Fatalf("无匹配 = %q", got) + } +} diff --git a/example/langs/code.json b/example/langs/code.json new file mode 100644 index 0000000..fdca24d --- /dev/null +++ b/example/langs/code.json @@ -0,0 +1 @@ +{"login_success":200,"error":400,"welcome":200} \ No newline at end of file diff --git a/example/langs/en.json b/example/langs/en.json new file mode 100644 index 0000000..fea0385 --- /dev/null +++ b/example/langs/en.json @@ -0,0 +1 @@ +{"login_success":"login success","error":"operation failed","welcome":"Hello #name#, welcome back"} \ No newline at end of file diff --git a/example/langs/zh.json b/example/langs/zh.json new file mode 100644 index 0000000..4f5e97c --- /dev/null +++ b/example/langs/zh.json @@ -0,0 +1 @@ +{"login_success":"登录成功","error":"操作失败","welcome":"你好 #name#, 欢迎回来"} \ No newline at end of file diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..e9c7e3a --- /dev/null +++ b/example/main.go @@ -0,0 +1,58 @@ +// 本示例演示 langx 的典型用法: 用 embed 装载资源、按语言取值、从 context 协商语言。 +// +// 运行: go run ./example +package main + +import ( + "context" + "embed" + "fmt" + + "github.com/yuninks/langx" +) + +//go:embed langs +var langFS embed.FS + +func main() { + // 1. 配置(可选): 默认语言、未知 key 的响应码、漏翻回调 + if err := langx.InitLangx( + langx.SetDefaultLanguage("zh"), + langx.SetDefaultCode(500), // code 会被当作 HTTP 状态码时, 建议让缺失的 key 以失败收场 + langx.SetMissingHandler(func(lang, key string) { + fmt.Printf("[warn] 缺少译文: lang=%s key=%s\n", lang, key) + }), + ); err != nil { + panic(err) + } + + // 2. 装载资源, 四选一: + // langx.RegisterEmbed(langFS) // embed.FS(推荐, 单文件部署) + // langx.RegisterDir("langs") // 磁盘目录 + // langx.RegisterFS(os.DirFS("langs")) // 任意 fs.FS + // langx.RegisterTrans("zh", map[string]string{"hello": "你好"}) // 直接给 map + if err := langx.RegisterEmbed(langFS); err != nil { + panic(err) + } + + // 3. 按语言直接取值 + code, msg := langx.GetTrans("zh", "login_success") + fmt.Printf("zh login_success => code=%d msg=%s\n", code, msg) + + code, msg = langx.GetTransFormat("en", "welcome", map[string]string{"name": "Tom"}) + fmt.Printf("en welcome => code=%d msg=%s\n", code, msg) + + // 4. 从请求 context 取语言: 显式设置 > Accept-Language 协商 > 默认语言 + // 实际项目中可把 http.Request.Header.Get("Accept-Language") 放进 context + ctx := context.WithValue(context.Background(), "Accept-Language", "en-US,en;q=0.9") + fmt.Printf("请求头 en-US => 协商语言=%s, welcome=%s\n", + langx.GetCtxLang(ctx), langx.GetFormatCtx(ctx, "welcome", map[string]string{"name": "Tom"})) + + ctx = langx.SetCtxLang(context.Background(), "zh") + fmt.Printf("显式指定 zh => welcome=%s\n", + langx.GetFormatCtx(ctx, "welcome", map[string]string{"name": "张三"})) + + // 5. 其它 + fmt.Println("已注册语言:", langx.Langs()) + fmt.Println("未知 key 的 code:", langx.GetCode("not_exists")) +} diff --git a/langx.go b/langx.go index 583c432..a1ddb2d 100644 --- a/langx.go +++ b/langx.go @@ -1,287 +1,328 @@ +// Package langx 提供轻量的多语言响应能力: 注册响应码与多语言译文, 按语言(含区域回退)取值, +// 并支持占位符替换。 +// +// 典型用法: +// +// _ = langx.InitLangx(langx.SetDefaultLanguage("zh"), langx.SetDefaultCode(500)) +// _ = langx.RegisterDir("langs") // 也可以是 RegisterEmbed / RegisterFS / RegisterTrans +// code, msg := langx.GetTransFormatCtx(ctx, "login_success", map[string]string{"name": "张三"}) +// +// 并发说明: 注册与读取可以安全地并发进行。读取路径无锁, 并且总是看到某个完整快照, +// 不会出现读到一半被写坏的情况; 所有写入都串行执行并原子发布新快照。 +// 注册时会对传入的 map 做深拷贝, 注册之后调用方修改自己的 map 不会影响库内数据。 package langx import ( "context" - "embed" - "encoding/json" - "fmt" - "io/fs" - "os" - "path/filepath" + "sort" "strings" "sync" + "sync/atomic" ) -type langx struct { - ops *options - codeMap map[string]int - transMap map[string]map[string]string - mut sync.RWMutex +// state 是一份不可变快照: 一旦被 current 发布, 其中的 map 就不会再被修改。 +// 读路径直接使用它, 因此无需加锁; 写路径总是构造新快照后再发布。 +type state struct { + ops options + code map[string]int + trans map[string]map[string]string // key 为归一化后的语言名 } -var l *langx = &langx{ - ops: defaultOptions(), - codeMap: make(map[string]int), - transMap: make(map[string]map[string]string), - mut: sync.RWMutex{}, -} +var ( + current atomic.Pointer[state] + writeMu sync.Mutex +) -// 这是语言的Code -func RegisterCode(datas map[string]int) { - l.mut.Lock() - defer l.mut.Unlock() - if datas == nil { - datas = make(map[string]int) - } - l.codeMap = datas -} - -// 追加覆盖Code -func AppendCode(datas map[string]int) { - l.mut.Lock() - defer l.mut.Unlock() - - if datas == nil { - datas = make(map[string]int) - } - if l.codeMap == nil { - l.codeMap = map[string]int{} - } - - for k, v := range datas { - l.codeMap[k] = v +func newState() *state { + return &state{ + ops: defaultOptions(), + code: map[string]int{}, + trans: map[string]map[string]string{}, } } -// 注册语言翻译 -func RegisterTrans(langName string, trans map[string]string) { - l.mut.Lock() - defer l.mut.Unlock() - l.transMap[langName] = trans +func init() { + current.Store(newState()) } -// 追加覆盖翻译 -func AppendTrans(langName string, trans map[string]string) { - l.mut.Lock() - defer l.mut.Unlock() - for k, v := range trans { - if l.transMap[langName] == nil { - l.transMap[langName] = map[string]string{} - } - l.transMap[langName][k] = v - } +// load 返回当前快照, 一次调用内应复用同一个快照以保证数据一致 +func load() *state { return current.Load() } + +// update 串行执行写操作并发布新快照 +func update(fn func(old *state) *state) { + writeMu.Lock() + defer writeMu.Unlock() + current.Store(fn(current.Load())) } -// 直接读取文件夹获取配置 -// 要求: -// 1.json格式文件 -// 2.code.json为自定义响应码 格式map[string]int{} -// 3.其他的json文件为对应语音 格式map[string]string{} -// 4.如果json解析错误将会panic -func RegisterDir(dir string) error { - // 遍历dir获取.json的文件 - err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error { - if info.IsDir() { - return nil - } - if strings.HasSuffix(info.Name(), ".json") { - // 读取文件 - fileName := strings.Replace(info.Name(), ".json", "", 1) - by, err := os.ReadFile(path) - if err != nil { - return err - } - if fileName == "code" { - data := map[string]int{} - err = json.Unmarshal(by, &data) - if err != nil { - return err - } - RegisterCode(data) - } else { - data := map[string]string{} - err = json.Unmarshal(by, &data) - if err != nil { - return err - } - RegisterTrans(fileName, data) - } - } - - return nil - }) - - // code文件为状态码,其他为对应的语言文件,文件名为语言名 - return err -} - -func RegisterEmbed(asset embed.FS) error { - paths, err := readEmbedAllPath(asset, "") +// updateErr 与 update 相同, 但 fn 返回错误时不发布任何变更 +func updateErr(fn func(old *state) (*state, error)) error { + writeMu.Lock() + defer writeMu.Unlock() + next, err := fn(current.Load()) if err != nil { return err } - - for _, ff := range paths { - // 获取文件的后缀 - - fileName := strings.Replace(ff.path, ".json", "", 1) - if fileName == "code" { - data := map[string]int{} - err = json.Unmarshal(ff.datas, &data) - if err != nil { - return err - } - RegisterCode(data) - } else { - data := map[string]string{} - err = json.Unmarshal(ff.datas, &data) - if err != nil { - return err - } - RegisterTrans(fileName, data) - } - } - + current.Store(next) return nil } -type fileData struct { - path string - datas []byte +// ---- 快照构造: 只创建新 map, 绝不修改已经发布的 map ---- + +func setOptions(s *state, ops options) *state { + return &state{ops: ops, code: s.code, trans: s.trans} } -func readEmbedAllPath(asset embed.FS, path string) ([]fileData, error) { - newRoot := "" - if path == "" { - newRoot = "." - } else { - newRoot = path - } - - fs, err := asset.ReadDir(newRoot) - if err != nil { - return nil, err - } - var files []fileData - for _, f := range fs { - if f.IsDir() { - chaild := "" - if newRoot == "." { - chaild = f.Name() - } else { - chaild = newRoot + "/" + f.Name() - } - - df, err := readEmbedAllPath(asset, chaild) - if err != nil { - return nil, err - } - files = append(files, df...) - continue +func setCodes(s *state, m map[string]int, merge bool) *state { + next := &state{ops: s.ops, trans: s.trans} + if !merge { + next.code = make(map[string]int, len(m)) + for k, v := range m { + next.code[k] = v } - - by, err := asset.ReadFile(newRoot + "/" + f.Name()) - if err != nil { - return nil, err - } - - ff := fileData{ - path: f.Name(), - datas: by, - } - - files = append(files, ff) + return next } - return files, nil + next.code = make(map[string]int, len(s.code)+len(m)) + for k, v := range s.code { + next.code[k] = v + } + for k, v := range m { + next.code[k] = v + } + return next } -// 获取翻译 -// 包含Code和Message -func GetTransFormat(lang string, key string, format map[string]string) (code int, str string) { - code = GetCode(key) - str = GetFormat(lang, key, format) - return +func setTrans(s *state, lang string, m map[string]string, merge bool) *state { + lang = normalizeLang(lang) + next := &state{ops: s.ops, code: s.code} + next.trans = make(map[string]map[string]string, len(s.trans)+1) + for k, v := range s.trans { + next.trans[k] = v // 内层 map 可以共享, 但绝不会被就地修改 + } + src := s.trans[lang] + if !merge { + src = nil + } + inner := make(map[string]string, len(src)+len(m)) + for k, v := range src { + inner[k] = v + } + for k, v := range m { + inner[k] = v + } + next.trans[lang] = inner + return next } +// ---- 注册 ---- + +// RegisterCode 整体替换响应码表, 传 nil 表示清空。 +func RegisterCode(datas map[string]int) { + update(func(old *state) *state { return setCodes(old, datas, false) }) +} + +// AppendCode 追加响应码, 已存在的 key 会被覆盖。 +func AppendCode(datas map[string]int) { + update(func(old *state) *state { return setCodes(old, datas, true) }) +} + +// RegisterTrans 整体替换某个语言的译文, 传 nil 表示清空该语言。 +func RegisterTrans(langName string, trans map[string]string) { + update(func(old *state) *state { return setTrans(old, langName, trans, false) }) +} + +// AppendTrans 追加某个语言的译文, 已存在的 key 会被覆盖。 +func AppendTrans(langName string, trans map[string]string) { + update(func(old *state) *state { return setTrans(old, langName, trans, true) }) +} + +// ---- 读取 ---- + +// GetTrans 取 key 对应的响应码与译文。 func GetTrans(lang string, key string) (code int, str string) { - return GetTransFormat(lang, key, map[string]string{}) + return GetTransFormat(lang, key, nil) } -func GetTransCtx(ctx context.Context, key string) (code int, str string) { - return GetTrans(getLangFromCtx(ctx), key) -} - -func GetTransFormatCtx(ctx context.Context, key string, format map[string]string) (code int, str string) { - return GetTransFormat(getLangFromCtx(ctx), key, format) -} - -// 根据Key获取code -func GetCode(key string) int { - l.mut.RLock() - defer l.mut.RUnlock() - code, ok := l.codeMap[key] +// GetTransFormat 取 key 对应的响应码与译文, 并把占位符替换为 format 中的值。 +// code 与 str 来自同一份快照, 不会出现撕裂。 +func GetTransFormat(lang string, key string, format map[string]string) (code int, str string) { + s := load() + code = codeOf(s, key) + str, ok := findMsg(s, lang, key) if !ok { - return l.ops.defaultCode + notifyMissing(s, lang, key) + return code, key } - return code + return code, applyFormat(s.ops, str, format) } -// 获取翻译 -func GetMsg(lang string, key string) string { - l.mut.RLock() - defer l.mut.RUnlock() - // 找指定语言 - str, ok := l.transMap[lang] - if ok { - val, ok := str[key] - if ok { - return val - } - } - // 找默认语言 - str, ok = l.transMap[l.ops.defaultLang] - if ok { - val, ok := str[key] - if ok { - return val - } - } +// GetTransCtx 从 context 中取语言, 再取响应码与译文。 +func GetTransCtx(ctx context.Context, key string) (code int, str string) { + return GetTrans(GetCtxLang(ctx), key) +} +// GetTransFormatCtx 从 context 中取语言, 再取响应码与译文并替换占位符。 +func GetTransFormatCtx(ctx context.Context, key string, format map[string]string) (code int, str string) { + return GetTransFormat(GetCtxLang(ctx), key, format) +} + +// GetCode 根据 key 取响应码, 未注册时返回默认码(默认 -1, 见 SetDefaultCode)。 +func GetCode(key string) int { + return codeOf(load(), key) +} + +// GetMsg 取译文: 先按语言查找(逐级去掉区域后缀), 再按默认语言查找, 都没有时返回 key 本身。 +func GetMsg(lang string, key string) string { + s := load() + if v, ok := findMsg(s, lang, key); ok { + return v + } + notifyMissing(s, lang, key) return key } -// 从ctx里面获取语言 +// GetMsgCtx 从 context 中取语言, 再取译文。 func GetMsgCtx(ctx context.Context, key string) string { - return GetMsg(getLangFromCtx(ctx), key) + return GetMsg(GetCtxLang(ctx), key) } -// 拼接回复 +// GetFormat 取译文并替换占位符, 未提供值的占位符会原样保留在结果里。 func GetFormat(lang string, key string, arr map[string]string) string { - str := GetMsg(lang, key) + s := load() + str, ok := findMsg(s, lang, key) + if !ok { + notifyMissing(s, lang, key) + str = key + } + return applyFormat(s.ops, str, arr) +} + +// GetFormatCtx 从 context 中取语言, 再取译文并替换占位符。 +func GetFormatCtx(ctx context.Context, key string, arr map[string]string) string { + return GetFormat(GetCtxLang(ctx), key, arr) +} + +// Has 判断按回退链(语言 -> 逐级去区域 -> 默认语言)能否取到该 key 的译文。 +func Has(lang string, key string) bool { + _, ok := findMsg(load(), lang, key) + return ok +} + +// HasCode 判断响应码表中是否注册了该 key。 +func HasCode(key string) bool { + _, ok := load().code[key] + return ok +} + +// Langs 返回已注册的语言名(已归一化为小写, 升序排列)。 +func Langs() []string { + s := load() + langs := make([]string, 0, len(s.trans)) + for name := range s.trans { + langs = append(langs, name) + } + sort.Strings(langs) + return langs +} + +// GetDefaultCode 返回未知 key 使用的响应码。 +func GetDefaultCode() int { + return load().ops.defaultCode +} + +// GetDefaultLang 返回默认(兜底)语言。 +func GetDefaultLang() string { + return load().ops.defaultLang +} + +// ---- 内部实现 ---- + +func codeOf(s *state, key string) int { + if code, ok := s.code[key]; ok { + return code + } + return s.ops.defaultCode +} + +// findMsg 依次尝试: 指定语言(逐级去区域后缀) -> 默认语言(同样逐级), 命中即返回 +func findMsg(s *state, lang string, key string) (string, bool) { + if v, ok := lookupMsg(s.trans, lang, key); ok { + return v, true + } + if normalizeLang(lang) == normalizeLang(s.ops.defaultLang) { + return "", false + } + return lookupMsg(s.trans, s.ops.defaultLang, key) +} + +// lookupMsg 在语言及其各级父语言中查找 key, 例如 zh-Hans-CN -> zh-hans -> zh。 +// 精确命中时不产生任何分配, 属于快路径。 +func lookupMsg(trans map[string]map[string]string, lang string, key string) (string, bool) { + l := normalizeLang(lang) + if v, ok := msgOf(trans[l], key); ok { + return v, true + } + for l != "" { + if v, ok := msgOf(trans[l], key); ok { + return v, true + } + // RFC 4647 basic filtering: 请求 en 时可以用 en-GB 兜底 + if best := bestPrefixLang(trans, l); best != "" { + if v, ok := msgOf(trans[best], key); ok { + return v, true + } + } + i := strings.LastIndexByte(l, '-') + if i <= 0 { + break + } + l = l[:i] + } + return "", false +} + +func msgOf(m map[string]string, key string) (string, bool) { + v, ok := m[key] + return v, ok +} + +// bestPrefixLang 返回以 "lang-" 开头且字典序最小的已注册语言, 保证结果稳定 +func bestPrefixLang(trans map[string]map[string]string, lang string) string { + if lang == "" { + return "" + } + prefix := lang + "-" + best := "" + for name := range trans { + if strings.HasPrefix(name, prefix) && (best == "" || name < best) { + best = name + } + } + return best +} + +// applyFormat 用 arr 替换 str 中的占位符 +func applyFormat(ops options, str string, arr map[string]string) string { + if str == "" || len(arr) == 0 { + return str + } + if ops.replacePrefix != "" && !strings.Contains(str, ops.replacePrefix) { + return str + } for k, v := range arr { - str = strings.ReplaceAll(str, fmt.Sprintf(l.ops.replaceKey, k), v) + str = strings.ReplaceAll(str, ops.replacePrefix+k+ops.replaceSuffix, v) } return str } -func GetFormatCtx(ctx context.Context, key string, arr map[string]string) string { - return GetFormat(getLangFromCtx(ctx), key, arr) -} - -// 获取默认Code -func GetDefaultCode() int { - return l.ops.defaultCode -} - -// 获取默认语言 -func GetDefaultLang() string { - return l.ops.defaultLang -} - -func getLangFromCtx(ctx context.Context) string { - lang := GetCtxLang(ctx) - if lang == "" { - lang = l.ops.defaultLang +// notifyMissing 在锁外回调用户注册的缺失处理器 +func notifyMissing(s *state, lang, key string) { + if s.ops.missing != nil { + s.ops.missing(lang, key) } - return lang +} + +// normalizeLang 归一化语言名: 去首尾空白并转小写 +func normalizeLang(lang string) string { + return strings.ToLower(strings.TrimSpace(lang)) } diff --git a/langx_test.go b/langx_test.go index f38757f..878dc73 100644 --- a/langx_test.go +++ b/langx_test.go @@ -1,49 +1,349 @@ package langx_test import ( + "context" + "fmt" + "strings" + "sync" "testing" "github.com/yuninks/langx" ) -const ( - Lang string = "s" -) - -var MapCode = map[string]int{ - Lang: 200, +// reset 保证每个用例都在干净状态下运行(本库是全局单例) +func reset(t *testing.T) { + t.Helper() + langx.Reset() + t.Cleanup(langx.Reset) } -func TestLangx(t *testing.T) { - langx.InitLangx( - langx.SetDefaultCode(0), - langx.SetDefaultLanguage("zh"), - ) +func mustInit(t *testing.T, ops ...langx.Option) { + t.Helper() + if err := langx.InitLangx(ops...); err != nil { + t.Fatalf("InitLangx() 返回错误: %v", err) + } +} - langx.RegisterCode(map[string]int{ - "login_success": 200, - "error": 400, - }) +func assertStrings(t *testing.T, what string, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s = %v, 期望 %v", what, got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("%s = %v, 期望 %v", what, got, want) + } + } +} + +func TestRegisterAndGet(t *testing.T) { + reset(t) + langx.RegisterCode(map[string]int{"login_success": 200, "error": 400}) + langx.RegisterTrans("zh", map[string]string{"login_success": "成功", "username": "你好 #name#"}) + langx.RegisterTrans("en", map[string]string{"login_success": "success"}) + + code, msg := langx.GetTrans("zh", "login_success") + if code != 200 || msg != "成功" { + t.Fatalf("GetTrans(zh) = (%d, %q), 期望 (200, 成功)", code, msg) + } + + // 指定语言缺失时回落默认语言 + if got := langx.GetMsg("en", "username"); got != "你好 #name#" { + t.Fatalf("默认语言兜底 = %q", got) + } + // 语言名归一化: 大小写与首尾空格 + if got := langx.GetMsg(" ZH ", "login_success"); got != "成功" { + t.Fatalf("语言名归一化 = %q", got) + } + // 区域回退: zh-CN -> zh, zh-Hans-CN -> zh-hans -> zh + if got := langx.GetMsg("zh-CN", "login_success"); got != "成功" { + t.Fatalf("zh-CN 回退 = %q", got) + } + if got := langx.GetMsg("zh-Hans-CN", "login_success"); got != "成功" { + t.Fatalf("zh-Hans-CN 回退 = %q", got) + } + // 未注册语言走默认语言 + if got := langx.GetMsg("fr", "login_success"); got != "成功" { + t.Fatalf("未注册语言 = %q", got) + } + // 未注册 key 返回 key 本身 + if got := langx.GetMsg("zh", "nope"); got != "nope" { + t.Fatalf("未注册 key = %q", got) + } + if langx.Has("zh", "nope") || !langx.Has("en", "login_success") { + t.Fatal("Has 判断不正确") + } +} + +func TestUnknownKeyCode(t *testing.T) { + reset(t) + langx.RegisterCode(map[string]int{"ok": 200}) + + // 默认不再返回成功码 + if got := langx.GetCode("nope"); got != -1 { + t.Fatalf("未知 key 的 code = %d, 期望 -1", got) + } + if code, msg := langx.GetTrans("zh", "nope"); code != -1 || msg != "nope" { + t.Fatalf("GetTrans(未知 key) = (%d, %q)", code, msg) + } + if langx.HasCode("nope") { + t.Fatal("HasCode(未知 key) 应为 false") + } + if !langx.HasCode("ok") { + t.Fatal("HasCode(ok) 应为 true") + } + + // 可配置为失败码 + mustInit(t, langx.SetDefaultCode(500)) + if got := langx.GetCode("nope"); got != 500 { + t.Fatalf("SetDefaultCode(500) 后 = %d", got) + } + if got := langx.GetDefaultCode(); got != 500 { + t.Fatalf("GetDefaultCode() = %d", got) + } +} + +func TestMissingHandler(t *testing.T) { + reset(t) + var calls []string + mustInit(t, langx.SetMissingHandler(func(lang, key string) { + // 回调内再次调用本库不应死锁 + _ = langx.Langs() + calls = append(calls, lang+"|"+key) + })) + langx.RegisterTrans("zh", map[string]string{"a": "A"}) + + if got := langx.GetMsg("zh", "missing"); got != "missing" { + t.Fatalf("GetMsg = %q", got) + } + if len(calls) != 1 || calls[0] != "zh|missing" { + t.Fatalf("缺失回调 = %v", calls) + } + // 命中时不触发 + _ = langx.GetMsg("zh", "a") + _, _ = langx.GetTransFormat("zh", "a", nil) + if len(calls) != 1 { + t.Fatalf("命中时不应触发回调, 实际 %v", calls) + } + // 取消回调 + mustInit(t, langx.SetMissingHandler(nil)) + _ = langx.GetMsg("zh", "missing2") + if len(calls) != 1 { + t.Fatalf("取消回调后仍被调用: %v", calls) + } +} + +func TestFormat(t *testing.T) { + reset(t) langx.RegisterTrans("zh", map[string]string{ - "login_success": "成功", - "error": "错误", - "username": "你好 #name#", // 有占位符 + "greet": "你好 #name#, 欢迎来到 #place#, #name#", + "plain": "没有占位符", }) - langx.RegisterTrans("en", map[string]string{ - "login_success": "success", - "error": "error", - "username": "Hello #name#", // 有占位符 - }) - // 获取翻译码 - code, msg := langx.GetTransFormat("zh", "login_success", nil) - t.Log(code, msg) - code, msg = langx.GetTransFormat("en", "error", nil) - t.Log(code, msg) - // 获取翻译码,有占位符 - code, msg = langx.GetTransFormat("zh", "username", map[string]string{ - "name": "张三", - }) - t.Log(code, msg) + if got := langx.GetFormat("zh", "greet", map[string]string{"name": "张三", "place": "北京"}); got != "你好 张三, 欢迎来到 北京, 张三" { + t.Fatalf("GetFormat = %q", got) + } + // 没有提供值的占位符原样保留, 便于发现问题 + if got := langx.GetFormat("zh", "greet", map[string]string{"name": "张三"}); !strings.Contains(got, "#place#") { + t.Fatalf("缺失参数时应保留占位符, 实际 %q", got) + } + // 模板没有占位符 / 参数为 nil / 空参数 + if got := langx.GetFormat("zh", "plain", map[string]string{"name": "张三"}); got != "没有占位符" { + t.Fatalf("无占位符模板 = %q", got) + } + if got := langx.GetFormat("zh", "plain", nil); got != "没有占位符" { + t.Fatalf("nil 参数 = %q", got) + } + // 未注册的 key 也能得到替换后的 key(保持向后兼容) + if got := langx.GetFormat("zh", "who_#name#", map[string]string{"name": "x"}); got != "who_x" { + t.Fatalf("未注册 key = %q", got) + } + // 自定义占位符模式 + mustInit(t, langx.SetReplaceKey("{{%s}}")) + langx.RegisterTrans("zh", map[string]string{"t": "hi {{name}}"}) + if got := langx.GetFormat("zh", "t", map[string]string{"name": "x"}); got != "hi x" { + t.Fatalf("自定义模式 = %q", got) + } + // 模式只写占位符本身 + mustInit(t, langx.SetReplaceKey("%s")) + if got := langx.GetFormat("zh", "t", map[string]string{"{{name}}": "x"}); got != "hi x" { + t.Fatalf("裸 %%s 模式 = %q", got) + } +} + +func TestInitLangxRejectsInvalidOption(t *testing.T) { + reset(t) + mustInit(t, langx.SetReplaceKey("{{%s}}")) + langx.RegisterTrans("zh", map[string]string{"t": "hi {{name}}"}) + + for _, bad := range []string{"", "#{}#", "#%d#", "%s%s", "no-verb"} { + if err := langx.InitLangx(langx.SetReplaceKey(bad)); err == nil { + t.Fatalf("SetReplaceKey(%q) 应当返回错误", bad) + } + // 非法配置必须完全不生效 + if got := langx.GetFormat("zh", "t", map[string]string{"name": "x"}); got != "hi x" { + t.Fatalf("非法配置生效了: replaceKey=%q, 结果 %q", bad, got) + } + } + // 合法模式仍然可用 + mustInit(t, langx.SetReplaceKey("#%s#")) + if err := langx.InitLangx(langx.SetDefaultLanguage("zh")); err != nil { + t.Fatalf("合法配置报错: %v", err) + } + if err := langx.InitLangx(langx.SetDefaultLanguage(" ")); err == nil { + t.Fatal("空默认语言应当报错") + } + if got := langx.GetDefaultLang(); got != "zh" { + t.Fatalf("非法默认语言生效了: %q", got) + } +} + +func TestRegistrationDoesNotAliasCaller(t *testing.T) { + reset(t) + trans := map[string]string{"k": "v1"} + langx.RegisterTrans("zh", trans) + trans["k"] = "v2" + trans["added"] = "x" + if got := langx.GetMsg("zh", "k"); got != "v1" { + t.Fatalf("注册后调用方修改 map 影响了库内数据: %q", got) + } + if langx.Has("zh", "added") { + t.Fatal("注册后调用方新增的 key 进入了库内") + } + + codes := map[string]int{"a": 1} + langx.RegisterCode(codes) + codes["a"] = 2 + if got := langx.GetCode("a"); got != 1 { + t.Fatalf("RegisterCode 后调用方修改 map 影响了库内数据: %d", got) + } + + // AppendTrans 不得污染调用方传入的 map + caller := map[string]string{"a": "1"} + langx.RegisterTrans("en", caller) + langx.AppendTrans("en", map[string]string{"b": "2"}) + if _, ok := caller["b"]; ok { + t.Fatalf("AppendTrans 污染了调用方的 map: %v", caller) + } + if got := langx.GetMsg("en", "b"); got != "2" { + t.Fatalf("AppendTrans 未生效: %q", got) + } +} + +func TestRegisterSemantics(t *testing.T) { + reset(t) + langx.AppendTrans("zh", map[string]string{"a": "1"}) + langx.AppendTrans("zh", map[string]string{"b": "2"}) + assertStrings(t, "Langs()", langx.Langs(), []string{"zh"}) + if langx.GetMsg("zh", "a") != "1" || langx.GetMsg("zh", "b") != "2" { + t.Fatal("AppendTrans 应累加") + } + // RegisterTrans 整体替换 + langx.RegisterTrans("zh", map[string]string{"c": "3"}) + if langx.Has("zh", "a") || langx.GetMsg("zh", "c") != "3" { + t.Fatal("RegisterTrans 应整体替换该语言") + } + // 传 nil 清空 + langx.RegisterTrans("zh", nil) + assertStrings(t, "Langs()", langx.Langs(), []string{"zh"}) + if langx.Has("zh", "c") { + t.Fatal("RegisterTrans(nil) 应清空该语言") + } + // AppendCode 累加, RegisterCode 替换 + langx.AppendCode(map[string]int{"a": 1}) + langx.AppendCode(map[string]int{"b": 2}) + if langx.GetCode("a") != 1 || langx.GetCode("b") != 2 { + t.Fatal("AppendCode 应累加") + } + langx.RegisterCode(map[string]int{"c": 3}) + if langx.HasCode("a") || langx.GetCode("c") != 3 { + t.Fatal("RegisterCode 应整体替换") + } + // 多语言按名排序 + langx.RegisterTrans("en", map[string]string{"x": "y"}) + langx.RegisterTrans("ZH-CN", map[string]string{"x": "z"}) + assertStrings(t, "Langs()", langx.Langs(), []string{"en", "zh", "zh-cn"}) +} + +func TestContextPriority(t *testing.T) { + reset(t) + langx.RegisterTrans("zh", map[string]string{"k": "中文"}) + langx.RegisterTrans("en", map[string]string{"k": "English"}) + mustInit(t, langx.SetDefaultLanguage("zh")) + + // 显式设置优先于请求头 + ctx := langx.SetCtxLang(context.Background(), "en") + ctx = context.WithValue(ctx, "Accept-Language", "zh-CN,zh;q=0.9") + if got := langx.GetMsgCtx(ctx, "k"); got != "English" { + t.Fatalf("显式语言应优先, 实际 %q", got) + } + if _, got := langx.GetTransFormatCtx(ctx, "k", nil); got != "English" { + t.Fatalf("GetTransFormatCtx = %q", got) + } + // ctx 中显式指定的是 en, 因此取到英文 + if got := langx.GetFormatCtx(ctx, "k", map[string]string{"n": "1"}); got != "English" { + t.Fatalf("GetFormatCtx = %q", got) + } +} + +func TestConcurrentAccess(t *testing.T) { + reset(t) + langx.RegisterTrans("zh", map[string]string{"k": "v0"}) + langx.RegisterCode(map[string]int{"ok": 200}) + + var wg sync.WaitGroup + stop := make(chan struct{}) + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _, msg := langx.GetTrans("zh-CN", "k") + if msg == "" { + t.Error("读取到空译文") + return + } + _ = langx.GetCode("ok") + _ = langx.Langs() + _ = langx.GetDefaultLang() + _ = langx.Has("zh", "k") + } + }() + } + for i := 0; i < 200; i++ { + langx.AppendTrans("zh", map[string]string{"k": fmt.Sprintf("v%d", i)}) + langx.AppendCode(map[string]int{"n": i}) + langx.AppendTrans("en", map[string]string{"k": fmt.Sprintf("e%d", i)}) + if err := langx.InitLangx(langx.SetDefaultCode(i)); err != nil { + t.Errorf("InitLangx: %v", err) + break + } + } + close(stop) + wg.Wait() +} + +func TestReset(t *testing.T) { + reset(t) + langx.RegisterTrans("zh", map[string]string{"k": "v"}) + langx.RegisterCode(map[string]int{"c": 1}) + mustInit(t, langx.SetDefaultCode(500), langx.SetDefaultLanguage("en")) + + langx.Reset() + assertStrings(t, "Langs()", langx.Langs(), nil) + if langx.HasCode("c") { + t.Fatal("Reset 未清空响应码") + } + if got := langx.GetDefaultLang(); got != "zh" { + t.Fatalf("Reset 未恢复默认语言: %q", got) + } + if got := langx.GetDefaultCode(); got != -1 { + t.Fatalf("Reset 未恢复默认码: %d", got) + } } diff --git a/loader.go b/loader.go new file mode 100644 index 0000000..468501c --- /dev/null +++ b/loader.go @@ -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") +} diff --git a/loader_test.go b/loader_test.go new file mode 100644 index 0000000..f7a6aad --- /dev/null +++ b/loader_test.go @@ -0,0 +1,195 @@ +package langx_test + +import ( + "embed" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/yuninks/langx" +) + +//go:embed testdata/langs +var embeddedLangs embed.FS + +func writeFile(t *testing.T, dir, rel, body string) { + t.Helper() + p := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("创建目录失败: %v", err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatalf("写入文件失败: %v", err) + } +} + +func TestRegisterDir(t *testing.T) { + reset(t) + dir := t.TempDir() + writeFile(t, dir, "code.json", "{\"login_success\":200,\"error\":400}") + writeFile(t, dir, "zh.json", "{\"login_success\":\"成功\",\"welcome\":\"你好 #name#\"}") + writeFile(t, dir, "en-US.json", "{\"login_success\":\"success\"}") + writeFile(t, dir, "notes.txt", "这不是 json") + writeFile(t, dir, "zh/extra.json", "{\"bye\":\"再见\"}") // 子目录并入 zh + writeFile(t, dir, "en-US/more.json", "{\"bye\":\"bye\"}") // 子目录并入 en-US + + if err := langx.RegisterDir(dir); err != nil { + t.Fatalf("RegisterDir: %v", err) + } + assertStrings(t, "Langs()", langx.Langs(), []string{"en-us", "zh"}) + if got := langx.GetCode("login_success"); got != 200 { + t.Fatalf("code.json 未生效: %d", got) + } + if got := langx.GetMsg("zh", "bye"); got != "再见" { + t.Fatalf("子目录文件未并入 zh: %q", got) + } + if got := langx.GetMsg("en-US", "bye"); got != "bye" { + t.Fatalf("子目录文件未并入 en-US: %q", got) + } + if got := langx.GetMsg("en-GB", "login_success"); got != "success" { + t.Fatalf("区域回退失败: %q", got) + } + // 再次加载同一目录: 合并语义, 不报错也不丢数据 + if err := langx.RegisterDir(dir); err != nil { + t.Fatalf("重复加载: %v", err) + } + if got := langx.GetMsg("zh", "login_success"); got != "成功" { + t.Fatalf("重复加载后数据异常: %q", got) + } +} + +// 旧实现在目录不存在时会 panic +func TestRegisterDirMissing(t *testing.T) { + reset(t) + if err := langx.RegisterDir(filepath.Join(t.TempDir(), "not-exist")); err == nil { + t.Fatal("目录不存在时应返回错误") + } + if err := langx.RegisterDir(" "); err == nil { + t.Fatal("空目录名应返回错误") + } + if err := langx.RegisterDir(t.TempDir()); err != nil { + t.Fatalf("空目录应加载成功(无数据): %v", err) + } +} + +// 解析失败必须整体失败, 不能留下半加载状态 +func TestRegisterDirInvalidJSON(t *testing.T) { + reset(t) + dir := t.TempDir() + writeFile(t, dir, "zh.json", "{\"k\":\"v\"}") + writeFile(t, dir, "en.json", "{坏掉的 json") + writeFile(t, dir, "code.json", "{\"k\":200}") + + if err := langx.RegisterDir(dir); err == nil { + t.Fatal("非法 json 应返回错误") + } + if len(langx.Langs()) != 0 || langx.HasCode("k") { + t.Fatalf("加载失败却写入了数据: langs=%v", langx.Langs()) + } +} + +// 结构不符合约定时也应报错 +func TestRegisterDirWrongSchema(t *testing.T) { + reset(t) + dir := t.TempDir() + writeFile(t, dir, "code.json", "{\"k\":\"not-int\"}") + if err := langx.RegisterDir(dir); err == nil { + t.Fatal("code.json 类型错误应返回错误") + } + dir2 := t.TempDir() + writeFile(t, dir2, "zh.json", "[\"not\",\"object\"]") + if err := langx.RegisterDir(dir2); err == nil { + t.Fatal("语言文件类型错误应返回错误") + } +} + +func TestRegisterFSContainer(t *testing.T) { + reset(t) + fsys := fstest.MapFS{ + "i18n/code.json": {Data: []byte("{\"ok\":200}")}, + "i18n/zh.json": {Data: []byte("{\"hello\":\"你好\"}")}, + "i18n/en.json": {Data: []byte("{\"hello\":\"Hello\"}")}, + "i18n/README.md": {Data: []byte("忽略我")}, + "i18n/zh/extra.json": {Data: []byte("{\"bye\":\"再见\"}")}, + } + if err := langx.RegisterFS(fsys); err != nil { + t.Fatalf("RegisterFS: %v", err) + } + // 容器目录 i18n 被自动跳过, 非 json 文件被忽略 + assertStrings(t, "Langs()", langx.Langs(), []string{"en", "zh"}) + if got := langx.GetCode("ok"); got != 200 { + t.Fatalf("容器内的 code.json 未生效: %d", got) + } + if got := langx.GetMsg("zh", "hello"); got != "你好" { + t.Fatalf("GetMsg = %q", got) + } + if got := langx.GetMsg("zh", "bye"); got != "再见" { + t.Fatalf("语言子目录未合并: %q", got) + } +} + +// 只有语言目录、根目录没有 json 的布局 +func TestRegisterFSLanguageDirs(t *testing.T) { + reset(t) + fsys := fstest.MapFS{ + "langs/zh/a.json": {Data: []byte("{\"a\":\"甲\"}")}, + "langs/zh/b.json": {Data: []byte("{\"b\":\"乙\"}")}, + "langs/en/a.json": {Data: []byte("{\"a\":\"A\"}")}, + "langs/code.json": {Data: []byte("{\"ok\":200}")}, + } + if err := langx.RegisterFS(fsys); err != nil { + t.Fatalf("RegisterFS: %v", err) + } + assertStrings(t, "Langs()", langx.Langs(), []string{"en", "zh"}) + if got := langx.GetMsg("zh", "a"); got != "甲" { + t.Fatalf("zh/a.json = %q", got) + } + if got := langx.GetMsg("zh", "b"); got != "乙" { + t.Fatalf("zh/b.json 未合并 = %q", got) + } + if got := langx.GetMsg("en", "a"); got != "A" { + t.Fatalf("en/a.json = %q", got) + } + if got := langx.GetCode("ok"); got != 200 { + t.Fatalf("code.json 未生效: %d", got) + } +} + +func TestRegisterFSErrors(t *testing.T) { + reset(t) + if err := langx.RegisterFS(nil); err == nil { + t.Fatal("nil fs.FS 应返回错误") + } + fsys := fstest.MapFS{"zh.json": {Data: []byte("{坏")}} + if err := langx.RegisterFS(fsys); err == nil { + t.Fatal("非法 json 应返回错误") + } + if len(langx.Langs()) != 0 { + t.Fatalf("失败后不应有数据: %v", langx.Langs()) + } + // 没有任何 json 文件 + empty := fstest.MapFS{"readme.md": {Data: []byte("x")}} + if err := langx.RegisterFS(empty); err != nil { + t.Fatalf("无 json 文件不应报错: %v", err) + } +} + +func TestRegisterEmbed(t *testing.T) { + reset(t) + if err := langx.RegisterEmbed(embeddedLangs); err != nil { + t.Fatalf("RegisterEmbed: %v", err) + } + // testdata/langs 是容器目录, 会被自动跳过; notes.txt 会被忽略 + assertStrings(t, "Langs()", langx.Langs(), []string{"en", "zh"}) + if got := langx.GetCode("login_success"); got != 200 { + t.Fatalf("响应码未加载: %d", got) + } + code, msg := langx.GetTransFormat("en", "welcome", map[string]string{"name": "Tom"}) + if code != 200 || msg != "Hello Tom" { + t.Fatalf("GetTransFormat = (%d, %q)", code, msg) + } + if got := langx.GetMsg("zh", "bye"); got != "再见" { + t.Fatalf("testdata/langs/zh/extra.json 未合并: %q", got) + } +} diff --git a/options.go b/options.go index ec33529..8bc8d64 100644 --- a/options.go +++ b/options.go @@ -1,54 +1,124 @@ package langx +import ( + "fmt" + "strings" +) + +// options 是库的全局配置。 +// +// 它是值类型, 一旦随快照发布就不再被修改: 任何配置变更都必须构造一份新的 options +// 并通过 update/updateErr 原子替换, 这样读路径可以完全不加锁, 也不会与写入产生竞态。 type options struct { + // defaultCode 是未知 key 返回的响应码。 + // 默认 -1, 表示"未定义", 避免 key 拼写错误时静默返回成功码。 defaultCode int + + // defaultLang 是默认语言(已归一化), 兜底时使用。 defaultLang string - replaceKey string - // ctxLangKey string + + // replaceKey 是占位符模式, 形如 "#%s#"。 + replaceKey string + + // replacePrefix/replaceSuffix 由 replaceKey 推导, 避免每次替换都做一次 Sprintf。 + replacePrefix string + replaceSuffix string + + // missing 是译文缺失时的回调, 可为 nil。 + missing func(lang, key string) } -func defaultOptions() *options { - return &options{ - defaultCode: 200, +func defaultOptions() options { + o := options{ + defaultCode: -1, defaultLang: "zh", replaceKey: "#%s#", - // ctxLangKey: "language", } + o.derive() + return o } -// 设置 -func InitLangx(ops ...Option) { - for _, opt := range ops { - opt(l.ops) +// derive 由 replaceKey 推导出占位符的前缀与后缀 +func (o *options) derive() { + const probe = "\x00langx\x00" + out := fmt.Sprintf(o.replaceKey, probe) + i := strings.Index(out, probe) + if i < 0 { + o.replacePrefix, o.replaceSuffix = "", "" + return } + o.replacePrefix = out[:i] + o.replaceSuffix = out[i+len(probe):] } +func (o options) validate() error { + if strings.TrimSpace(o.defaultLang) == "" { + return fmt.Errorf("langx: 默认语言不能为空") + } + return validateReplaceKey(o.replaceKey) +} + +// validateReplaceKey 要求模式中有且只有一个 %s +func validateReplaceKey(pattern string) error { + if pattern == "" { + return fmt.Errorf("langx: replaceKey 不能为空, 形如 %q", "#%s#") + } + out := fmt.Sprintf(pattern, "x") + if strings.Contains(out, "%!") || !strings.Contains(out, "x") { + return fmt.Errorf("langx: replaceKey=%q 非法, 需要且只需要一个 %%s 占位符, 例如 %q", pattern, "#%s#") + } + return nil +} + +// Option 是配置项, 通过 InitLangx 应用。 type Option func(*options) -// 设置默认的code +// InitLangx 应用配置项, 可重复调用, 后一次在前一次的配置上叠加。 +// +// 与旧版本的区别: 配置非法时返回错误, 且本次传入的所有 Option 都不会生效(不会写入一半)。 +// 不需要错误处理时可以直接当语句使用: langx.InitLangx(...) +func InitLangx(ops ...Option) error { + return updateErr(func(old *state) (*state, error) { + next := old.ops // 值拷贝, 不影响已经发布的配置 + for _, opt := range ops { + if opt == nil { + continue + } + opt(&next) + } + if err := next.validate(); err != nil { + return nil, err + } + next.derive() + return setOptions(old, next), nil + }) +} + +// Reset 清空所有已注册的响应码与译文, 并恢复默认配置。便于测试与重新初始化。 +func Reset() { + update(func(*state) *state { return newState() }) +} + +// SetDefaultCode 设置未知 key 返回的响应码, 默认 -1。 +// +// 如果 code 会被直接当作 HTTP 状态码使用, 建议设置为 500, 让缺失的 key 以失败收场。 func SetDefaultCode(code int) Option { - return func(o *options) { - o.defaultCode = code - } + return func(o *options) { o.defaultCode = code } } -// 从ctx里面获取语言的key -// func SetCtxLangKey(key string) Option { -// return func(o *options) { -// o.ctxLangKey = key -// } -// } - -// 默认语言 +// SetDefaultLanguage 设置默认(兜底)语言, 默认 "zh"。 func SetDefaultLanguage(lang string) Option { - return func(o *options) { - o.defaultLang = lang - } + return func(o *options) { o.defaultLang = normalizeLang(lang) } } -// 替换规则 %s 为占位符 需要填进去 +// SetReplaceKey 设置占位符模式, 必须包含且只包含一个 %s, 默认 "#%s#"(即 #name#)。 func SetReplaceKey(key string) Option { - return func(o *options) { - o.replaceKey = key - } + return func(o *options) { o.replaceKey = key } +} + +// SetMissingHandler 设置译文缺失时的回调, 便于记录漏翻的 key。传 nil 表示取消回调。 +// +// 回调在读取路径上同步执行, 且不在任何锁内, 因此可以安全地再次调用本库的函数。 +func SetMissingHandler(fn func(lang, key string)) Option { + return func(o *options) { o.missing = fn } } diff --git a/readme.md b/readme.md index 393f2ba..c816808 100644 --- a/readme.md +++ b/readme.md @@ -1,9 +1,186 @@ +# langx -# 简介 -这是简单的一个多语言响应工具,能够通过多种方式加载资源文件,然后进行匹配。同时支持占位符替换的形式在多语言响应的时候替换特定字符,让响应更友好。 +轻量的 Go 多语言响应库:注册响应码与多语言译文,按语言(支持区域回退)取值,并支持占位符替换。零第三方依赖,`go 1.19` 起可用。 -# 使用方法 +```go +code, msg := langx.GetTransFormatCtx(ctx, "login_success", map[string]string{"name": "张三"}) +// code = 200, msg = "你好 张三, 登录成功" +``` -看example +## 特性 +- **资源来源灵活**:`embed.FS`、磁盘目录、任意 `fs.FS`,或直接传 map。 +- **语言协商**:解析 `Accept-Language`(q 值排序 + `zh-CN → zh` 区域回退 + RFC 4647 前缀匹配,请求 `en-US` 可命中已注册的 `en-GB`)。 +- **占位符替换**:默认 `#name#` 形式,模式可自定义;缺少参数时占位符原样保留,便于及早发现。 +- **并发安全**:读取路径无锁,注册与读取可安全并行,永远不会读到"写了一半"的数据。 +- **明确的失败**:未知 key 默认不再返回成功码;资源文件解析失败时整体失败,不会写入半个文件。 +- **不篡改调用方数据**:注册时深拷贝,注册之后调用方修改自己的 map 不会影响库内数据。 +## 安装 + +```bash +go get github.com/yuninks/langx +``` + +## 快速开始 + +完整可运行示例见 [example/main.go](example/main.go),执行 `go run ./example` 即可查看输出。 + +```go +package main + +import ( + "context" + "embed" + "fmt" + + "github.com/yuninks/langx" +) + +//go:embed langs +var langFS embed.FS + +func main() { + // 1. 可选配置:默认语言、未知 key 的响应码、漏翻回调 + if err := langx.InitLangx( + langx.SetDefaultLanguage("zh"), + langx.SetDefaultCode(500), // code 若会作为 HTTP 状态码,建议让缺失的 key 以失败收场 + langx.SetMissingHandler(func(lang, key string) { + fmt.Printf("缺少译文: lang=%s key=%s\n", lang, key) + }), + ); err != nil { + panic(err) + } + + // 2. 装载资源(四选一) + if err := langx.RegisterEmbed(langFS); err != nil { // 或 RegisterDir("langs") / RegisterFS(fsys) + panic(err) + } + + // 3. 取值 + code, msg := langx.GetTrans("zh", "login_success") + + // 4. 带占位符 + code, msg = langx.GetTransFormat("en", "welcome", map[string]string{"name": "Tom"}) + + // 5. 从请求 context 取语言:显式设置 > Accept-Language 协商 > 默认语言 + ctx := context.WithValue(context.Background(), "Accept-Language", "en-US,en;q=0.9") + msg = langx.GetMsgCtx(ctx, "login_success") +} +``` + +## 资源文件约定 + +`RegisterDir` / `RegisterFS` / `RegisterEmbed` 使用同一套规则: + +| 路径 | 含义 | +| --- | --- | +| `zh.json` | 语言 `zh` 的译文,格式 `map[string]string` | +| `en-US.json` | 语言 `en-US` 的译文(语言名不区分大小写,统一按小写归一化) | +| `code.json`(根目录) | 响应码,格式 `map[string]int` | +| `zh/common.json`、`zh/errors.json` | 子目录第一段是语言名,多个文件按键合并进 `zh` | +| `README.md` 等非 json 文件 | 直接忽略 | +| `langs/`(唯一子目录且自身没有 json) | 视为容器目录,自动向内继续查找(最多 8 层) | + +规则要点: + +- 加载是**合并**语义:多次调用会累加,同一个 key 以最后一次为准。 +- 加载是**事务**的:任意文件解析失败都会返回错误,且不会改变已加载的数据。 +- 根目录下的 `code.json` 是保留文件名;更深层的 `code.json`(如 `zh/code.json`)按该语言的普通文件处理。 + +## API 一览 + +### 注册 + +| 函数 | 说明 | +| --- | --- | +| `RegisterCode(map[string]int)` | 整体替换响应码表,传 `nil` 清空 | +| `AppendCode(map[string]int)` | 追加/覆盖响应码 | +| `RegisterTrans(lang, map[string]string)` | 整体替换某语言译文 | +| `AppendTrans(lang, map[string]string)` | 追加/覆盖某语言译文 | +| `RegisterDir(dir string) error` | 从磁盘目录加载 | +| `RegisterFS(fsys fs.FS) error` | 从任意 `fs.FS` 加载 | +| `RegisterEmbed(asset embed.FS) error` | 从 `embed.FS` 加载 | + +### 取值 + +| 函数 | 说明 | +| --- | --- | +| `GetTrans(lang, key) (int, string)` | 取响应码与译文 | +| `GetTransFormat(lang, key, map[string]string) (int, string)` | 同上,并替换占位符 | +| `GetCode(key) int` | 只取响应码 | +| `GetMsg(lang, key) string` | 只取译文 | +| `GetFormat(lang, key, map[string]string) string` | 只取译文并替换占位符 | +| `GetTransCtx` / `GetTransFormatCtx` / `GetMsgCtx` / `GetFormatCtx` | 从 `context.Context` 取语言 | +| `Has(lang, key) bool` / `HasCode(key) bool` | 判断译文/响应码是否存在 | +| `Langs() []string` | 已注册语言(小写、升序) | + +### 配置 + +| 函数 | 默认值 | 说明 | +| --- | --- | --- | +| `InitLangx(...Option) error` | — | 应用配置;可重复调用;配置非法时返回错误且完全不生效 | +| `SetDefaultLanguage(lang)` | `zh` | 默认(兜底)语言 | +| `SetDefaultCode(code)` | `-1` | 未知 key 的响应码 | +| `SetReplaceKey(pattern)` | `#%s#` | 占位符模式,必须包含且只包含一个 `%s` | +| `SetMissingHandler(fn)` | `nil` | 译文缺失回调,便于记录漏翻的 key | +| `Reset()` | — | 清空全部数据并恢复默认配置(测试用) | + +## 语言协商 + +`GetCtxLang(ctx)` 按以下优先级返回语言: + +1. `langx.SetCtxLang(ctx, "en")` 显式设置的值(即使该语言没有注册也照用); +2. `context` 中的 `Accept-Language`(`string` 或 `[]string`,后者兼容 `http.Header` 风格),交给 `NegotiateLang` 与已注册语言协商; +3. 默认语言 `GetDefaultLang()`。 + +协商规则(`ParseAcceptLanguage` / `NegotiateLang` 也已导出,可单独使用): + +- 按 `q` 值从高到低排序,`q=0` 表示明确不接受、会被丢弃,`*` 视为"任意"并回落默认语言; +- 每个候选先精确匹配,再逐级去掉区域后缀(`zh-Hans-CN → zh-hans → zh`),最后按语言前缀匹配(请求 `en-US` 可命中已注册的 `en-GB`)。 + +取值时的回退链同样是:指定语言 → 逐级去区域 → 语言前缀 → 默认语言(同样逐级);全都取不到时返回 key 本身,并触发 `SetMissingHandler` 回调。 + +## 占位符 + +```go +langx.RegisterTrans("zh", map[string]string{"welcome": "你好 #name#, 欢迎回来"}) +langx.GetFormat("zh", "welcome", map[string]string{"name": "张三"}) // 你好 张三, 欢迎回来 +``` + +- 模式由 `SetReplaceKey` 配置,必须包含且只包含一个 `%s`;写成 `#{}#`、`#%d#` 这类非法模式会在 `InitLangx` 时报错,而不是静默失效。 +- 同一个占位符出现多次会全部替换;没有提供值的占位符原样保留。 +- 未注册的 key 也会走替换逻辑,方便过渡期使用动态 key。 + +## 并发模型 + +- 读取(`Get*`)基于不可变快照,**完全无锁**;一次调用内使用同一份快照,因此 `GetTransFormat` 返回的 code 与 msg 一定是一致的。 +- 写入(注册与配置)串行执行并原子发布新快照:读取要么看到旧数据,要么看到新数据,不会看到中间状态,也不存在数据竞态。 +- 频繁写入会重建索引,建议把注册放在启动阶段;运行期更新适合用 `AppendTrans` 做增量补丁。 + +## 从 v0.0.7 升级 + +本次修复包含以下**行为变更**,请按需调整: + +| 变更 | 说明 | +| --- | --- | +| `InitLangx` 现在返回 `error` | 当语句调用不受影响;非法 `replaceKey`(如 `#{}#`)此前会静默失效,现在返回错误且配置完全不生效 | +| 未知 key 的默认响应码 `200` → `-1` | 旧行为会让拼错的错误码静默变成"成功";需要旧行为可显式 `SetDefaultCode(200)`,HTTP 场景建议 `SetDefaultCode(500)` | +| 注册不再与调用方共享 map | 依赖"注册后修改自己的 map 仍然生效"的代码需要改为再次调用注册函数 | +| 加载器只处理 `.json`,子目录参与语言名推导 | 此前子目录中的同名文件会互相覆盖、`en/common.json` 会被当成语言 `common`;现在子目录第一段即语言名并按键合并 | +| 加载失败不再留下半加载状态 | 此前 `RegisterEmbed` 遇到非 json 文件会中断,已注册的部分仍然生效 | +| `GetCtxLang` 返回协商后的语言 | 此前会原样返回请求头字符串(如 `en-US,en;q=0.9`),并在 context 值不是 string 时 panic | +| `RegisterCode` / `RegisterTrans` / `AppendCode` / `AppendTrans` 语义未变 | 整体替换 / 追加覆盖的语义与旧版一致 | + +## 开发 + +```bash +gofmt -l . # 应为空 +go vet ./... +go test -race ./... +go run ./example +``` + +## License + +尚未添加 LICENSE 文件,需要仓库作者确定许可协议后再补充。 diff --git a/testdata/langs/code.json b/testdata/langs/code.json new file mode 100644 index 0000000..fdca24d --- /dev/null +++ b/testdata/langs/code.json @@ -0,0 +1 @@ +{"login_success":200,"error":400,"welcome":200} \ No newline at end of file diff --git a/testdata/langs/en.json b/testdata/langs/en.json new file mode 100644 index 0000000..a7f6acb --- /dev/null +++ b/testdata/langs/en.json @@ -0,0 +1 @@ +{"login_success":"success","welcome":"Hello #name#"} \ No newline at end of file diff --git a/testdata/langs/notes.txt b/testdata/langs/notes.txt new file mode 100644 index 0000000..0d70a07 --- /dev/null +++ b/testdata/langs/notes.txt @@ -0,0 +1 @@ +这不是 json, 应当被忽略 \ No newline at end of file diff --git a/testdata/langs/zh.json b/testdata/langs/zh.json new file mode 100644 index 0000000..19cc518 --- /dev/null +++ b/testdata/langs/zh.json @@ -0,0 +1 @@ +{"login_success":"成功","welcome":"你好 #name#"} \ No newline at end of file diff --git a/testdata/langs/zh/extra.json b/testdata/langs/zh/extra.json new file mode 100644 index 0000000..0b508d4 --- /dev/null +++ b/testdata/langs/zh/extra.json @@ -0,0 +1 @@ +{"bye":"再见"} \ No newline at end of file