├── translations ├── sr_RS.json ├── en_US.json └── de_DE.json ├── locale_test.go ├── LICENSE.md ├── lingo_test.go ├── locale.go ├── README.md └── lingo.go /translations/sr_RS.json: -------------------------------------------------------------------------------- 1 | { 2 | "main.title" : "CutleryPlus", 3 | "main.subtitle" : "Escajg za svakoga", 4 | "menu" : { 5 | "home" : "Pocetna", 6 | "products": { 7 | "self": "Proizvodi", 8 | "forks" : "Viljuske", 9 | "knives" : "Nozevi", 10 | "spoons" : "Kasike" 11 | }, 12 | "gallery" : "Galerija", 13 | "about" : "O nama", 14 | "contact" : "Kontakt" 15 | }, 16 | "home" : { 17 | "title": "Dobrodosli u CutleryPlus!", 18 | "text" : { 19 | "p1": "Lorem ipsum...", 20 | "p2": "Jos jedan ipsum lorem." 21 | } 22 | }, 23 | "error" : { 24 | "404" : "Stranica {0} ne postoji.", 25 | "500" : "Greska sa nase strane, pokusajte ponovo.", 26 | "contact" : { 27 | "name" : "Ime ne sme biti prazno.", 28 | "email" : "Email ne sme biti prazan.", 29 | "text" : "Ne mozete poslati praznu poruku." 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /translations/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "main.title" : "CutleryPlus", 3 | "main.subtitle" : "Knives that put cut in cutlery.", 4 | "menu" : { 5 | "home" : "Home", 6 | "products": { 7 | "self": "Products", 8 | "forks" : "Forks", 9 | "knives" : "Knives", 10 | "spoons" : "Spoons" 11 | }, 12 | "gallery" : "Gallery", 13 | "about" : "About us", 14 | "contact" : "Contact" 15 | }, 16 | "home" : { 17 | "title": "Welcome to CutleryPlus!", 18 | "text" : { 19 | "p1": "Lorem ipsum...", 20 | "p2": "Another ipsum lorem." 21 | } 22 | }, 23 | "error" : { 24 | "404" : "Page {0} not found!", 25 | "500" : "Something is wrong on our side, please try again.", 26 | "contact" : { 27 | "name" : "You must enter your name.", 28 | "email" : "You must enter your email.", 29 | "text" : "You cannot send an empty message." 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /translations/de_DE.json: -------------------------------------------------------------------------------- 1 | { 2 | "main.title" : "CutleryPlus", 3 | "main.subtitle" : "Messer, die legte in Besteck geschnitten.", 4 | "menu" : { 5 | "home" : "Home", 6 | "products": { 7 | "self": "Produkte", 8 | "forks" : "Gabeln", 9 | "knives" : "Messer", 10 | "spoons" : "Löffel" 11 | }, 12 | "gallery" : "Galerie", 13 | "about" : "Über uns", 14 | "contact" : "Kontakt" 15 | }, 16 | "home" : { 17 | "title": "Willkommen in CutleryPlus!", 18 | "text" : { 19 | "p1": "Lorem ipsum...", 20 | "p2": "Ein weiterer ipsum lorem." 21 | } 22 | }, 23 | "error" : { 24 | "404" : "Seite {0} wurde nicht gefunden.", 25 | "500" : "Stimmt etwas nicht auf unserer Seite ist, versuchen Sie es erneut.", 26 | "contact" : { 27 | "name" : "Sie müssen Ihren Namen eingeben.", 28 | "email" : "Sie müssen Ihre E-Mail ein.", 29 | "text" : "Sie können eine leere Nachricht nicht zu senden." 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /locale_test.go: -------------------------------------------------------------------------------- 1 | package lingo 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLocale(t *testing.T) { 8 | l0 := supportedLocales("ja-JP;q") 9 | if len(l0) != 1 { 10 | t.Errorf("Expected number of locales \"1\", got %d", len(l0)) 11 | t.Fail() 12 | } 13 | l1 := supportedLocales("en,de-AT; q=0.8,de;q=0.6,bg; q=0.4,en-US;q=0.2,sr;q=0.2") 14 | if len(l1) != 6 { 15 | t.Errorf("Expected number of locales \"6\", got %d", len(l1)) 16 | t.Fail() 17 | } 18 | l2 := supportedLocales("en") 19 | if len(l2) != 1 { 20 | t.Errorf("Expected number of locales \"1\", got %d", len(l2)) 21 | t.Fail() 22 | } 23 | l3 := supportedLocales("") 24 | if len(l3) != 0 { 25 | t.Errorf("Expected number of locales \"0\", got %d", len(l3)) 26 | t.Fail() 27 | } 28 | l4 := ParseLocale("en_US") 29 | if l4.Lang != "en" || l4.Country != "US" { 30 | t.Errorf("Expected \"en\" and \"US\", got %s and %s", l4.Lang, l4.Country) 31 | t.Fail() 32 | } 33 | l5 := ParseLocale("en") 34 | if l5.Lang != "en" || l5.Country != "" { 35 | t.Errorf("Expected \"en\" and \"\", got %s and %s", l5.Lang, l5.Country) 36 | t.Fail() 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Dusan Lilic 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. 22 | -------------------------------------------------------------------------------- /lingo_test.go: -------------------------------------------------------------------------------- 1 | package lingo 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | ) 9 | 10 | func TestLingo(t *testing.T) { 11 | l := New("de_DE", "translations") 12 | t1 := l.TranslationsForLocale("en_US") 13 | r1 := t1.Value("main.subtitle") 14 | r1Exp := "Knives that put cut in cutlery." 15 | if r1 != r1Exp { 16 | t.Errorf("Expected \""+r1Exp+"\", got %s", r1) 17 | t.Fail() 18 | } 19 | r2 := t1.Value("home.title") 20 | r2Exp := "Welcome to CutleryPlus!" 21 | if r2 != r2Exp { 22 | t.Errorf("Expected \""+r2Exp+"\", got %s", r2) 23 | t.Fail() 24 | } 25 | r3 := t1.Value("menu.products.self") 26 | r3Exp := "Products" 27 | if r3 != r3Exp { 28 | t.Errorf("Expected \""+r3Exp+"\", got %s", r3) 29 | t.Fail() 30 | } 31 | r4 := t1.Value("menu.non.existant") 32 | r4Exp := "non.existant" 33 | if r4 != r4Exp { 34 | t.Errorf("Expected \""+r4Exp+"\", got %s", r4) 35 | t.Fail() 36 | } 37 | r5 := t1.Value("error.404", "idnex.html") 38 | r5Exp := "Page idnex.html not found!" 39 | if r5 != r5Exp { 40 | t.Errorf("Expected \""+r5Exp+"\", got \"%s\"", r5) 41 | t.Fail() 42 | } 43 | } 44 | 45 | func TestLingoHttp(t *testing.T) { 46 | l := New("en_US", "translations") 47 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 | expected := r.Header.Get("Expected-Results") 49 | t1 := l.TranslationsForRequest(r) 50 | r1 := t1.Value("error.500") 51 | if r1 != expected { 52 | t.Errorf("Expected \""+expected+"\", got %s", r1) 53 | t.Fail() 54 | } 55 | })) 56 | defer srv.Close() 57 | url, _ := url.Parse(srv.URL) 58 | 59 | req1 := &http.Request{ 60 | Method: "GET", 61 | Header: map[string][]string{ 62 | "Accept-Language": {"sr, en-gb;q=0.8, en;q=0.7"}, 63 | "Expected-Results": {"Greska sa nase strane, pokusajte ponovo."}, 64 | }, 65 | URL: url, 66 | } 67 | req2 := &http.Request{ 68 | Method: "GET", 69 | Header: map[string][]string{ 70 | "Accept-Language": {"en-US, en-gb;q=0.8, en;q=0.7"}, 71 | "Expected-Results": {"Something is wrong on our side, please try again."}, 72 | }, 73 | URL: url, 74 | } 75 | req3 := &http.Request{ 76 | Method: "GET", 77 | Header: map[string][]string{ 78 | "Accept-Language": {"de-at, en-gb;q=0.8, en;q=0.7"}, 79 | "Expected-Results": {"Stimmt etwas nicht auf unserer Seite ist, versuchen Sie es erneut."}, 80 | }, 81 | URL: url, 82 | } 83 | 84 | http.DefaultClient.Do(req1) 85 | http.DefaultClient.Do(req2) 86 | http.DefaultClient.Do(req3) 87 | } 88 | -------------------------------------------------------------------------------- /locale.go: -------------------------------------------------------------------------------- 1 | package lingo 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // Locale is locale value from the Accept-Language header in request 11 | type Locale struct { 12 | Lang, Country string 13 | Qual float64 14 | } 15 | 16 | // Name returns the locale value in 'lang' or 'lang_country' format 17 | // eg: de_DE, en_US, gb 18 | func (l *Locale) Name() string { 19 | if len(l.Country) > 0 { 20 | return l.Lang + "_" + l.Country 21 | } 22 | return l.Lang 23 | } 24 | 25 | // ParseLocale creates a Locale from a locale string 26 | func ParseLocale(locale string) Locale { 27 | locsplt := strings.Split(locale, "_") 28 | resp := Locale{} 29 | resp.Lang = locsplt[0] 30 | if len(locsplt) > 1 { 31 | resp.Country = locsplt[1] 32 | } 33 | return resp 34 | } 35 | 36 | const ( 37 | acceptLanguage = "Accept-Language" 38 | ) 39 | 40 | func supportedLocales(alstr string) []Locale { 41 | locales := make([]Locale, 0) 42 | alstr = strings.Replace(alstr, " ", "", -1) 43 | if alstr == "" { 44 | return locales 45 | } 46 | al := strings.Split(alstr, ",") 47 | for _, lstr := range al { 48 | locales = append(locales, Locale{ 49 | Lang: parseLang(lstr), 50 | Country: parseCountry(lstr), 51 | Qual: parseQual(lstr), 52 | }) 53 | } 54 | return locales 55 | } 56 | 57 | // GetLocales returns supported locales for the given requet 58 | func GetLocales(r *http.Request) []Locale { 59 | return supportedLocales(r.Header.Get(acceptLanguage)) 60 | } 61 | 62 | // GetPreferredLocale return preferred locale for the given reuqest 63 | // returns error if there is no preferred locale 64 | func GetPreferredLocale(r *http.Request) (*Locale, error) { 65 | locales := GetLocales(r) 66 | if len(locales) == 0 { 67 | return &Locale{}, errors.New("No locale found") 68 | } 69 | return &locales[0], nil 70 | } 71 | 72 | func parseLang(val string) string { 73 | locale := strings.Split(val, ";")[0] 74 | lang := strings.Split(locale, "-")[0] 75 | return lang 76 | } 77 | 78 | func parseCountry(val string) string { 79 | locale := strings.Split(val, ";")[0] 80 | spl := strings.Split(locale, "-") 81 | if len(spl) > 1 { 82 | return spl[1] 83 | } 84 | return "" 85 | } 86 | 87 | func parseQual(val string) float64 { 88 | spl := strings.Split(val, ";") 89 | if len(spl) > 1 { 90 | qualSpl := strings.Split(spl[1], "=") 91 | if len(qualSpl) > 1 { 92 | qual, err := strconv.ParseFloat(qualSpl[1], 64) 93 | if err != nil { 94 | return 1 95 | } 96 | return qual 97 | } 98 | } 99 | return 1 100 | } 101 | 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | lingo 2 | ===== 3 | 4 | Very basic Golang library for i18n. There are others that do the job, but this is my take on the problem. 5 | 6 | Features: 7 | --------- 8 | 1. Storing messages in JSON files. 9 | 2. Support for nested declarations. 10 | 2. Detecting language based on Request headers. 11 | 3. Very simple to use. 12 | 13 | Usage: 14 | ------ 15 | 1. Import Lingo into your project 16 | 17 | ```go 18 | import "github.com/kortem/lingo" 19 | ``` 20 | 1. Create a dir to store translations, and write them in JSON files named [locale].json. For example: 21 | 22 | ``` 23 | en_US.json 24 | sr_RS.json 25 | de.json 26 | ... 27 | ``` 28 | You can write nested JSON too. 29 | ```json 30 | { 31 | "main.title" : "CutleryPlus", 32 | "main.subtitle" : "Knives that put cut in cutlery.", 33 | "menu" : { 34 | "home" : "Home", 35 | "products": { 36 | "self": "Products", 37 | "forks" : "Forks", 38 | "knives" : "Knives", 39 | "spoons" : "Spoons" 40 | }, 41 | } 42 | } 43 | ``` 44 | 2. Initialize a Lingo like this: 45 | 46 | ```go 47 | l := lingo.New("default_locale", "path/to/translations/dir") 48 | ``` 49 | 50 | 3. Get bundle for specific locale via either `string`: 51 | 52 | ```go 53 | t1 := l.TranslationsForLocale("en_US") 54 | t2 := l.TranslationsForLocale("de_DE") 55 | ``` 56 | This way Lingo will return the bundle for specific locale, or default if given is not found. 57 | Alternatively (or primarily), you can get it with `*http.Request`: 58 | 59 | ```go 60 | t := l.TranslationsForRequest(req) 61 | ``` 62 | This way Lingo finds best suited locale via `Accept-Language` header, or if there is no match, returns default. 63 | `Accept-Language` header is set by the browser, so basically it will serve the language the user has set to his browser. 64 | 4. Once you get T instance just fire away! 65 | 66 | ```go 67 | r1 := t1.Value("main.subtitle") 68 | // "Knives that put cut in cutlery." 69 | r1 := t2.Value("main.subtitle") 70 | // "Messer, die legte in Besteck geschnitten." 71 | r3 := t1.Value("menu.products.self") 72 | // "Products" 73 | r5 := t1.Value("error.404", req.URL.Path) 74 | // "Page index.html not found!" 75 | ``` 76 | 77 | Contributions: 78 | ----- 79 | I regard this little library as feature-complete, but if you have an idea on how to improve it, feel free to create issues. Also, pull requests are welcome. Enjoy! 80 | -------------------------------------------------------------------------------- /lingo.go: -------------------------------------------------------------------------------- 1 | package lingo 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | // L represents Lingo bundle, containing map of all Ts by locale, 13 | // as well as default locale and list of supported locales 14 | type L struct { 15 | bundle map[string]T 16 | deflt string 17 | supported []Locale 18 | } 19 | 20 | func (l *L) exists(locale string) bool { 21 | _, exists := l.bundle[locale] 22 | return exists 23 | } 24 | 25 | // TranslationsForRequest will get the best matched T for given 26 | // Request. If no T is found, returns default T 27 | func (l *L) TranslationsForRequest(r *http.Request) T { 28 | locales := GetLocales(r) 29 | for _, locale := range locales { 30 | t, exists := l.bundle[locales[0].Name()] 31 | if exists { 32 | return t 33 | } 34 | for _, sup := range l.supported { 35 | if locale.Lang == sup.Lang { 36 | return l.bundle[sup.Name()] 37 | } 38 | } 39 | } 40 | return l.bundle[l.deflt] 41 | } 42 | 43 | // TranslationsForLocale will get the T for specific locale. 44 | // If no locale is found, returns default T 45 | func (l *L) TranslationsForLocale(locale string) T { 46 | t, exists := l.bundle[locale] 47 | if exists { 48 | return t 49 | } 50 | return l.bundle[l.deflt] 51 | } 52 | 53 | // T represents translations map for specific locale 54 | type T struct { 55 | transl map[string]interface{} 56 | } 57 | 58 | // Value traverses the translations map and finds translation for 59 | // given key. If no translation is found, returns value of given key. 60 | func (t T) Value(key string, args ...string) string { 61 | if t.exists(key) { 62 | res, ok := t.transl[key].(string) 63 | if ok { 64 | return t.parseArgs(res, args) 65 | } 66 | } 67 | ksplt := strings.Split(key, ".") 68 | for i := range ksplt { 69 | k1 := strings.Join(ksplt[0:i], ".") 70 | k2 := strings.Join(ksplt[i:len(ksplt)], ".") 71 | if t.exists(k1) { 72 | newt := &T{ 73 | transl: t.transl[k1].(map[string]interface{}), 74 | } 75 | return newt.Value(k2, args...) 76 | } 77 | } 78 | return key 79 | } 80 | 81 | // parseArgs replaces the argument placeholders with given arguments 82 | func (t T) parseArgs(value string, args []string) string { 83 | res := value 84 | for i := 0; i < len(args); i++ { 85 | tok := "{" + strconv.Itoa(i) + "}" 86 | res = strings.Replace(res, tok, args[i], -1) 87 | } 88 | return res 89 | } 90 | 91 | // exists checks if value exists for given key 92 | func (t T) exists(key string) bool { 93 | _, ok := t.transl[key] 94 | return ok 95 | } 96 | 97 | // New creates the Lingo bundle. 98 | // Params: 99 | // Default locale, to be used when requested locale 100 | // is not found. 101 | // Path, absolute or relative path to a folder where 102 | // translation .json files are kept 103 | func New(deflt, path string) *L { 104 | files, _ := ioutil.ReadDir(path) 105 | l := &L{ 106 | bundle: make(map[string]T), 107 | deflt: deflt, 108 | supported: make([]Locale, 0), 109 | } 110 | for _, f := range files { 111 | fileName := f.Name() 112 | dat, err := ioutil.ReadFile(path + "/" + fileName) 113 | if err != nil { 114 | log.Printf("Cannot read file %s, file corrupt.", fileName) 115 | log.Printf("Error: %s", err) 116 | continue 117 | } 118 | t := T{ 119 | transl: make(map[string]interface{}), 120 | } 121 | err = json.Unmarshal(dat, &t.transl) 122 | if err != nil { 123 | log.Printf("Cannot read file %s, invalid JSON.", fileName) 124 | log.Printf("Error: %s", err) 125 | continue 126 | } 127 | locale := strings.Split(fileName, ".")[0] 128 | l.supported = append(l.supported, ParseLocale(locale)) 129 | l.bundle[locale] = t 130 | } 131 | return l 132 | } 133 | --------------------------------------------------------------------------------