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") }