├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── formatter.go ├── i18n.go ├── i18n_test.go ├── json_source.go ├── source.go └── testdata ├── en-US ├── app.json └── error.json └── zh-CN ├── app.json └── error.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # all files 5 | [*] 6 | indent_style = tab 7 | indent_size = 4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.o 3 | *.a 4 | *.so 5 | *.exe 6 | *.test 7 | /out/ 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.9.x -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Openset 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # II18N 2 | 3 | [![GoDoc](https://godoc.org/github.com/syyongx/ii18n?status.svg)](https://godoc.org/github.com/syyongx/ii18n) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/syyongx/ii18n)](https://goreportcard.com/report/github.com/syyongx/ii18n) 5 | [![MIT licensed][3]][4] 6 | 7 | [3]: https://img.shields.io/badge/license-MIT-blue.svg 8 | [4]: LICENSE 9 | 10 | Go i18n library. 11 | 12 | ## Download & Install 13 | ```shell 14 | go get github.com/syyongx/ii18n 15 | ``` 16 | 17 | ## Quick Start 18 | ```go 19 | import github.com/syyongx/ii18n 20 | 21 | func main() { 22 | config := map[string]Config{ 23 | "app": Config{ 24 | SourceNewFunc: NewJSONSource, 25 | OriginalLang: "en-US", 26 | BasePath: "./testdata", 27 | FileMap: map[string]string{ 28 | "app": "app.json", 29 | "error": "error.json", 30 | }, 31 | }, 32 | } 33 | NewI18N(config) 34 | message := T("app", "hello", nil, "zh-CN") 35 | } 36 | ``` 37 | 38 | ## Apis 39 | ```go 40 | NewI18N(config map[string]Config) *I18N 41 | T(category string, message string, params map[string]string, lang string) string 42 | ``` 43 | 44 | ## LICENSE 45 | II18N source code is licensed under the [MIT](https://github.com/syyongx/ii18n/blob/master/LICENSE) Licence. 46 | -------------------------------------------------------------------------------- /formatter.go: -------------------------------------------------------------------------------- 1 | package ii18n 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // Formatter Formatter 9 | type Formatter struct { 10 | } 11 | 12 | // NewFormatter New Formatter 13 | func NewFormatter() *Formatter { 14 | return &Formatter{} 15 | } 16 | 17 | // format message 18 | func (f *Formatter) format(pattern string, params map[string]string, lang string) (string, error) { 19 | tokens := f.tokenizePattern(pattern) 20 | if tokens == nil { 21 | return "", errors.New("message pattern is invalid") 22 | } 23 | 24 | return strings.Join(tokens, ""), nil 25 | } 26 | 27 | // Tokenize a pattern by separating normal text from replaceable patterns. 28 | func (f *Formatter) tokenizePattern(pattern string) []string { 29 | pos := strings.Index(pattern, "{") 30 | if pos == -1 { 31 | return []string{pattern} 32 | } 33 | //pr := []rune(pattern) 34 | depth, length := 1, len(pattern) 35 | tokens := []string{pattern[:pos]} 36 | for { 37 | if pos+1 > length { 38 | break 39 | } 40 | open := strings.Index(pattern[pos+1:], "{") 41 | closing := strings.Index(pattern[pos+1:], "}") 42 | if open == -1 && closing == -1 { 43 | break 44 | } 45 | if open == -1 { 46 | open = length 47 | } 48 | if closing > open { 49 | depth++ 50 | pos = open 51 | } else { 52 | depth-- 53 | pos = closing 54 | } 55 | if depth == 0 { 56 | tokens = append(tokens, pattern[pos+1:open]) 57 | } 58 | if depth != 0 && (open == -1 || closing == -1) { 59 | break 60 | } 61 | } 62 | if depth != 0 { 63 | return nil 64 | } 65 | 66 | return tokens 67 | } 68 | -------------------------------------------------------------------------------- /i18n.go: -------------------------------------------------------------------------------- 1 | package ii18n 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "sync" 7 | ) 8 | 9 | // DefaultOriginalLang default original language 10 | var DefaultOriginalLang = "en-US" 11 | 12 | // Translator translator 13 | var Translator *I18N 14 | 15 | // T translate. 16 | // 1. T('common', 'hot', [], 'zh-CN') // default app.common 17 | // 2. T('app.common', 'hot', [], 'zh-CN') // result same to 1. 18 | // 3. T('msg.a', 'hello', ['{foo}' => 'bar', '{key}' => 'val'] 'ja-JP') 19 | func T(category string, message string, params map[string]string, lang string) string { 20 | if strings.Index(category, ".") == -1 { 21 | category = "app." + category 22 | } 23 | return Translator.translate(category, message, params, lang) 24 | } 25 | 26 | // Config config 27 | type Config struct { 28 | SourceNewFunc func(*Config) Source 29 | OriginalLang string 30 | ForceTranslation bool 31 | BasePath string 32 | FileMap map[string]string 33 | source Source 34 | } 35 | 36 | // I18N i18n 37 | type I18N struct { 38 | Translations map[string]*Config 39 | formatter Formatter 40 | mutex sync.RWMutex 41 | } 42 | 43 | // NewI18N returns an instance of I18N. 44 | func NewI18N(config map[string]Config) *I18N { 45 | Translator = &I18N{ 46 | Translations: make(map[string]*Config), 47 | } 48 | for key, conf := range config { 49 | if conf.SourceNewFunc == nil { 50 | panic("Config SourceNewFunc is illegal") 51 | } 52 | if conf.OriginalLang == "" { 53 | conf.OriginalLang = DefaultOriginalLang 54 | } 55 | if len(conf.OriginalLang) < 2 { 56 | panic("Config OriginalLang length cannot be less than 2") 57 | } 58 | if conf.BasePath == "" { 59 | panic("Config BasePath is illegal") 60 | } 61 | if conf.FileMap == nil { 62 | panic("Config FileMap is illegal") 63 | } 64 | if _, ok := Translator.Translations[key]; !ok { 65 | Translator.Translations[key] = &conf 66 | } 67 | } 68 | return Translator 69 | } 70 | 71 | // translate 72 | func (i *I18N) translate(category string, message string, params map[string]string, lang string) string { 73 | s, ol := i.getSource(category) 74 | translation, err := s.Translate(category, message, lang) 75 | if err != nil || translation == "" { 76 | return i.format(message, params, ol) 77 | } 78 | return i.format(translation, params, lang) 79 | } 80 | 81 | func (i *I18N) format(message string, params map[string]string, lang string) string { 82 | if params == nil { 83 | return message 84 | } 85 | if ok, _ := regexp.MatchString(`~{\s*[\d\w]+\s*,~u`, message); ok { 86 | result, err := i.formatter.format(message, params, lang) 87 | if err != nil { 88 | return message 89 | } 90 | return result 91 | } 92 | oldnew := make([]string, len(params)*2) 93 | for name, val := range params { 94 | oldnew = append(oldnew, "{"+name+"}", val) 95 | } 96 | return strings.NewReplacer(oldnew...).Replace(message) 97 | } 98 | 99 | // getFormatter Get the the message formatter. 100 | func (i *I18N) getFormatter(category string) Formatter { 101 | return i.formatter 102 | } 103 | 104 | // getSource Get the message source for the given category. 105 | func (i *I18N) getSource(category string) (Source, string) { 106 | prefix := strings.Split(category, ".")[0] 107 | if val, ok := i.Translations[prefix]; ok { 108 | i.mutex.Lock() 109 | defer i.mutex.Unlock() 110 | if val.source == nil { 111 | i.Translations[prefix].source = i.Translations[prefix].SourceNewFunc(i.Translations[prefix]) 112 | } 113 | return i.Translations[prefix].source, i.Translations[prefix].OriginalLang 114 | } 115 | panic("Unable to locate message source for category " + category + ".") 116 | } 117 | -------------------------------------------------------------------------------- /i18n_test.go: -------------------------------------------------------------------------------- 1 | package ii18n 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestTranslate(t *testing.T) { 9 | config := map[string]Config{ 10 | "app": { 11 | SourceNewFunc: NewJSONSource, 12 | OriginalLang: "en-US", 13 | BasePath: "./testdata", 14 | FileMap: map[string]string{ 15 | "app": "app.json", 16 | "error": "error.json", 17 | }, 18 | }, 19 | } 20 | NewI18N(config) 21 | res := T("app", "hello", nil, "zh-CN") 22 | fmt.Println(res) 23 | } 24 | -------------------------------------------------------------------------------- /json_source.go: -------------------------------------------------------------------------------- 1 | package ii18n 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "strings" 7 | ) 8 | 9 | // JSONSource JSONSource 10 | type JSONSource struct { 11 | MessageSource 12 | } 13 | 14 | // NewJSONSource New JSONSource 15 | func NewJSONSource(conf *Config) Source { 16 | s := &JSONSource{} 17 | s.OriginalLang = conf.OriginalLang 18 | s.BasePath = conf.BasePath 19 | s.ForceTranslation = conf.ForceTranslation 20 | s.FileMap = conf.FileMap 21 | s.messages = make(map[string]TMsgs) 22 | s.fileSuffix = "json" 23 | s.loadFunc = loadMsgsFromJSONFile 24 | 25 | return s 26 | } 27 | 28 | // GetMsgFilePath Get messages file path. 29 | func (js *JSONSource) GetMsgFilePath(category string, lang string) string { 30 | suffix := strings.Split(category, ".")[1] 31 | path := js.BasePath + "/" + lang + "/" 32 | if v, ok := js.FileMap[suffix]; !ok { 33 | path += v 34 | } else { 35 | path += strings.Replace(suffix, "\\", "/", -1) 36 | } 37 | return path 38 | } 39 | 40 | // loadMsgsFromJSONFile Get messages file path. 41 | func loadMsgsFromJSONFile(filename string) (TMsgs, error) { 42 | data, err := ioutil.ReadFile(filename) 43 | if err != nil { 44 | return nil, err 45 | } 46 | var msgs TMsgs 47 | e := json.Unmarshal(data, &msgs) 48 | if e != nil { 49 | return nil, e 50 | } 51 | 52 | return msgs, nil 53 | } 54 | -------------------------------------------------------------------------------- /source.go: -------------------------------------------------------------------------------- 1 | package ii18n 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "sync" 7 | ) 8 | 9 | // TMsgs type messages 10 | type TMsgs map[string]string 11 | 12 | // Source interface 13 | type Source interface { 14 | Translate(category string, message string, lang string) (string, error) 15 | TranslateMsg(category string, message string, lang string) (string, error) 16 | GetMsgFilePath(category string, lang string) string 17 | LoadMsgs(category string, lang string) (TMsgs, error) 18 | LoadFallbackMsgs(category string, fallbackLang string, msgs TMsgs, originalMsgFile string) (TMsgs, error) 19 | } 20 | 21 | // MessageSource MessageSource 22 | type MessageSource struct { 23 | // string the language that the original messages are in 24 | OriginalLang string 25 | ForceTranslation bool 26 | BasePath string 27 | FileMap map[string]string 28 | fileSuffix string 29 | loadFunc func(filename string) (TMsgs, error) 30 | messages map[string]TMsgs 31 | mutex sync.RWMutex 32 | } 33 | 34 | // Translate translate 35 | func (ms *MessageSource) Translate(category string, message string, lang string) (string, error) { 36 | if ms.ForceTranslation || lang != ms.OriginalLang { 37 | return ms.TranslateMsg(category, message, lang) 38 | } 39 | return "", nil 40 | } 41 | 42 | // TranslateMsg translate message 43 | func (ms *MessageSource) TranslateMsg(category string, message string, lang string) (string, error) { 44 | cates := strings.Split(category, ".") 45 | key := cates[0] + "/" + lang + "/" + cates[1] 46 | 47 | ms.mutex.RLock() 48 | defer ms.mutex.RUnlock() 49 | 50 | if _, ok := ms.messages[key]; !ok { 51 | val, err := ms.LoadMsgs(category, lang) 52 | if err != nil { 53 | return "", err 54 | } 55 | ms.messages[key] = val 56 | } 57 | if msg, ok := ms.messages[key][message]; ok && msg != "" { 58 | return msg, nil 59 | } 60 | 61 | ms.messages[key] = TMsgs{message: ""} 62 | return "", nil 63 | } 64 | 65 | // GetMsgFilePath Get messages file path. 66 | func (ms *MessageSource) GetMsgFilePath(category string, lang string) string { 67 | suffix := strings.Split(category, ".")[1] 68 | path := ms.BasePath + "/" + lang + "/" 69 | if v, ok := ms.FileMap[suffix]; !ok { 70 | path += v 71 | } else { 72 | path += strings.Replace(suffix, "\\", "/", -1) 73 | if ms.fileSuffix != "" { 74 | path += "." + ms.fileSuffix 75 | } 76 | } 77 | return path 78 | } 79 | 80 | // LoadMsgs Loads the message translation for the specified $language and $category. 81 | // If translation for specific locale code such as `en-US` isn't found it 82 | // tries more generic `en`. When both are present, the `en-US` messages will be merged 83 | // over `en`. See [[loadFallbackTMsgs]] for details. 84 | // If the lang is less specific than [[originalLang]], the method will try to 85 | // load the messages for [[originalLang]]. For example: [[originalLang]] is `en-GB`, 86 | // language is `en`. The method will load the messages for `en` and merge them over `en-GB`. 87 | func (ms *MessageSource) LoadMsgs(category string, lang string) (TMsgs, error) { 88 | msgFile := ms.GetMsgFilePath(category, lang) 89 | msgs, err := ms.loadFunc(msgFile) 90 | if err != nil { 91 | return nil, err 92 | } 93 | fbLang := lang[0:2] 94 | fbOriginalLang := ms.OriginalLang[0:2] 95 | if lang != fbLang { 96 | msgs, err = ms.LoadFallbackMsgs(category, fbLang, msgs, msgFile) 97 | } else if lang == fbOriginalLang { 98 | msgs, err = ms.LoadFallbackMsgs(category, ms.OriginalLang, msgs, msgFile) 99 | } else { 100 | if msgs == nil { 101 | return nil, errors.New("the message file for category " + category + " does not exist: " + msgFile) 102 | } 103 | } 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | return msgs, nil 109 | } 110 | 111 | // LoadFallbackMsgs Loads the message translation for the specified $language and $category. 112 | // If translation for specific locale code such as `en-US` isn't found it 113 | // tries more generic `en`. When both are present, the `en-US` messages will be merged 114 | func (ms *MessageSource) LoadFallbackMsgs(category string, fallbackLang string, msgs TMsgs, originalMsgFile string) (TMsgs, error) { 115 | fbMsgFile := ms.GetMsgFilePath(category, fallbackLang) 116 | fbMsgs, _ := ms.loadFunc(fbMsgFile) 117 | if msgs == nil && fbMsgs == nil && 118 | fallbackLang != ms.OriginalLang && 119 | fallbackLang != ms.OriginalLang[0:2] { 120 | return nil, errors.New("The message file for category " + category + " does not exist: " + originalMsgFile + " Fallback file does not exist as well: " + fbMsgFile) 121 | } else if msgs == nil { 122 | return fbMsgs, nil 123 | } else if fbMsgs != nil { 124 | ms.mutex.Lock() 125 | defer ms.mutex.Unlock() 126 | 127 | for key, val := range fbMsgs { 128 | v, ok := msgs[key] 129 | if val != "" && (!ok || v == "") { 130 | msgs[key] = val 131 | } 132 | } 133 | } 134 | 135 | return msgs, nil 136 | } 137 | 138 | // LoadMsgsFromFile Get messages file path. 139 | func LoadMsgsFromFile(filename string) (TMsgs, error) { 140 | return nil, nil 141 | } 142 | -------------------------------------------------------------------------------- /testdata/en-US/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "world", 3 | "nice": "ok" 4 | } -------------------------------------------------------------------------------- /testdata/en-US/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "parameter error", 3 | "warning": "parameter warning" 4 | } -------------------------------------------------------------------------------- /testdata/zh-CN/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "世界", 3 | "nice": "好的" 4 | } -------------------------------------------------------------------------------- /testdata/zh-CN/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "参数错误", 3 | "warning": "参数警告" 4 | } --------------------------------------------------------------------------------