diff options
| author | Christine Dodrill <me@christine.website> | 2019-03-13 07:16:47 -0700 |
|---|---|---|
| committer | Christine Dodrill <me@christine.website> | 2019-03-13 07:16:47 -0700 |
| commit | f586d6e2a46037c279e727a50d87caab24ad3e33 (patch) | |
| tree | 1f9dc2dae1f5bf890ffff206c1089638c005dfcc | |
| parent | a0ccb5341b04c83f8569d4ef2ea51ce96c26e060 (diff) | |
| download | x-f586d6e2a46037c279e727a50d87caab24ad3e33.tar.xz x-f586d6e2a46037c279e727a50d87caab24ad3e33.zip | |
i18n: import lingo
| -rw-r--r-- | i18n/LICENSE.md | 21 | ||||
| -rw-r--r-- | i18n/README.md | 79 | ||||
| -rw-r--r-- | i18n/legal.go | 29 | ||||
| -rw-r--r-- | i18n/lingo.go | 132 | ||||
| -rw-r--r-- | i18n/lingo_test.go | 87 | ||||
| -rw-r--r-- | i18n/locale.go | 101 | ||||
| -rw-r--r-- | i18n/locale_test.go | 39 | ||||
| -rw-r--r-- | i18n/translations/de_DE.json | 32 | ||||
| -rw-r--r-- | i18n/translations/en_US.json | 32 | ||||
| -rw-r--r-- | i18n/translations/sr_RS.json | 32 |
10 files changed, 584 insertions, 0 deletions
diff --git a/i18n/LICENSE.md b/i18n/LICENSE.md new file mode 100644 index 0000000..c72833f --- /dev/null +++ b/i18n/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Dusan Lilic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/i18n/README.md b/i18n/README.md new file mode 100644 index 0000000..c260ea2 --- /dev/null +++ b/i18n/README.md @@ -0,0 +1,79 @@ +lingo +===== + +Very basic Golang library for i18n. There are others that do the job, but this is my take on the problem. + +Features: +--------- +1. Storing messages in JSON files. +2. Support for nested declarations. +2. Detecting language based on Request headers. +3. Very simple to use. + +Usage: +------ + 1. Import Lingo into your project + + ```go + import "github.com/kortem/lingo" + ``` + 1. Create a dir to store translations, and write them in JSON files named [locale].json. For example: + + ``` + en_US.json + sr_RS.json + de.json + ... + ``` + You can write nested JSON too. + ```json + { + "main.title" : "CutleryPlus", + "main.subtitle" : "Knives that put cut in cutlery.", + "menu" : { + "home" : "Home", + "products": { + "self": "Products", + "forks" : "Forks", + "knives" : "Knives", + "spoons" : "Spoons" + }, + } + } + ``` + 2. Initialize a Lingo like this: + + ```go + l := lingo.New("default_locale", "path/to/translations/dir") + ``` + + 3. Get bundle for specific locale via either `string`: + + ```go + t1 := l.TranslationsForLocale("en_US") + t2 := l.TranslationsForLocale("de_DE") + ``` + This way Lingo will return the bundle for specific locale, or default if given is not found. + Alternatively (or primarily), you can get it with `*http.Request`: + + ```go + t := l.TranslationsForRequest(req) + ``` + This way Lingo finds best suited locale via `Accept-Language` header, or if there is no match, returns default. + `Accept-Language` header is set by the browser, so basically it will serve the language the user has set to his browser. + 4. Once you get T instance just fire away! + + ```go + r1 := t1.Value("main.subtitle") + // "Knives that put cut in cutlery." + r1 := t2.Value("main.subtitle") + // "Messer, die legte in Besteck geschnitten." + r3 := t1.Value("menu.products.self") + // "Products" + r5 := t1.Value("error.404", req.URL.Path) + // "Page index.html not found!" + ``` + +Contributions: +----- +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! diff --git a/i18n/legal.go b/i18n/legal.go new file mode 100644 index 0000000..7f68dff --- /dev/null +++ b/i18n/legal.go @@ -0,0 +1,29 @@ +package i18n + +import "go4.org/legal" + +func init() { + legal.RegisterLicense(`i18n library of this software copyright: + +The MIT License (MIT) + +Copyright (c) 2014 Dusan Lilic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.`) +} diff --git a/i18n/lingo.go b/i18n/lingo.go new file mode 100644 index 0000000..4824c01 --- /dev/null +++ b/i18n/lingo.go @@ -0,0 +1,132 @@ +package i18n + +import ( + "encoding/json" + "io/ioutil" + "log" + "net/http" + "strconv" + "strings" +) + +// L represents Lingo bundle, containing map of all Ts by locale, +// as well as default locale and list of supported locales +type L struct { + bundle map[string]T + deflt string + supported []Locale +} + +func (l *L) exists(locale string) bool { + _, exists := l.bundle[locale] + return exists +} + +// TranslationsForRequest will get the best matched T for given +// Request. If no T is found, returns default T +func (l *L) TranslationsForRequest(r *http.Request) T { + locales := GetLocales(r) + for _, locale := range locales { + t, exists := l.bundle[locales[0].Name()] + if exists { + return t + } + for _, sup := range l.supported { + if locale.Lang == sup.Lang { + return l.bundle[sup.Name()] + } + } + } + return l.bundle[l.deflt] +} + +// TranslationsForLocale will get the T for specific locale. +// If no locale is found, returns default T +func (l *L) TranslationsForLocale(locale string) T { + t, exists := l.bundle[locale] + if exists { + return t + } + return l.bundle[l.deflt] +} + +// T represents translations map for specific locale +type T struct { + transl map[string]interface{} +} + +// Value traverses the translations map and finds translation for +// given key. If no translation is found, returns value of given key. +func (t T) Value(key string, args ...string) string { + if t.exists(key) { + res, ok := t.transl[key].(string) + if ok { + return t.parseArgs(res, args) + } + } + ksplt := strings.Split(key, ".") + for i := range ksplt { + k1 := strings.Join(ksplt[0:i], ".") + k2 := strings.Join(ksplt[i:len(ksplt)], ".") + if t.exists(k1) { + newt := &T{ + transl: t.transl[k1].(map[string]interface{}), + } + return newt.Value(k2, args...) + } + } + return key +} + +// parseArgs replaces the argument placeholders with given arguments +func (t T) parseArgs(value string, args []string) string { + res := value + for i := 0; i < len(args); i++ { + tok := "{" + strconv.Itoa(i) + "}" + res = strings.Replace(res, tok, args[i], -1) + } + return res +} + +// exists checks if value exists for given key +func (t T) exists(key string) bool { + _, ok := t.transl[key] + return ok +} + +// New creates the Lingo bundle. +// Params: +// Default locale, to be used when requested locale +// is not found. +// Path, absolute or relative path to a folder where +// translation .json files are kept +func New(deflt, path string) *L { + files, _ := ioutil.ReadDir(path) + l := &L{ + bundle: make(map[string]T), + deflt: deflt, + supported: make([]Locale, 0), + } + for _, f := range files { + fileName := f.Name() + dat, err := ioutil.ReadFile(path + "/" + fileName) + if err != nil { + log.Printf("Cannot read file %s, file corrupt.", fileName) + log.Printf("Error: %s", err) + continue + } + t := T{ + transl: make(map[string]interface{}), + } + err = json.Unmarshal(dat, &t.transl) + if err != nil { + log.Printf("Cannot read file %s, invalid JSON.", fileName) + log.Printf("Error: %s", err) + continue + } + locale := strings.Split(fileName, ".")[0] + l.supported = append(l.supported, ParseLocale(locale)) + l.bundle[locale] = t + } + return l +} diff --git a/i18n/lingo_test.go b/i18n/lingo_test.go new file mode 100644 index 0000000..ca1f559 --- /dev/null +++ b/i18n/lingo_test.go @@ -0,0 +1,87 @@ +package i18n + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestLingo(t *testing.T) { + l := New("de_DE", "translations") + t1 := l.TranslationsForLocale("en_US") + r1 := t1.Value("main.subtitle") + r1Exp := "Knives that put cut in cutlery." + if r1 != r1Exp { + t.Errorf("Expected \""+r1Exp+"\", got %s", r1) + t.Fail() + } + r2 := t1.Value("home.title") + r2Exp := "Welcome to CutleryPlus!" + if r2 != r2Exp { + t.Errorf("Expected \""+r2Exp+"\", got %s", r2) + t.Fail() + } + r3 := t1.Value("menu.products.self") + r3Exp := "Products" + if r3 != r3Exp { + t.Errorf("Expected \""+r3Exp+"\", got %s", r3) + t.Fail() + } + r4 := t1.Value("menu.non.existant") + r4Exp := "non.existant" + if r4 != r4Exp { + t.Errorf("Expected \""+r4Exp+"\", got %s", r4) + t.Fail() + } + r5 := t1.Value("error.404", "idnex.html") + r5Exp := "Page idnex.html not found!" + if r5 != r5Exp { + t.Errorf("Expected \""+r5Exp+"\", got \"%s\"", r5) + t.Fail() + } +} + +func TestLingoHttp(t *testing.T) { + l := New("en_US", "translations") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := r.Header.Get("Expected-Results") + t1 := l.TranslationsForRequest(r) + r1 := t1.Value("error.500") + if r1 != expected { + t.Errorf("Expected \""+expected+"\", got %s", r1) + t.Fail() + } + })) + defer srv.Close() + url, _ := url.Parse(srv.URL) + + req1 := &http.Request{ + Method: "GET", + Header: map[string][]string{ + "Accept-Language": {"sr, en-gb;q=0.8, en;q=0.7"}, + "Expected-Results": {"Greska sa nase strane, pokusajte ponovo."}, + }, + URL: url, + } + req2 := &http.Request{ + Method: "GET", + Header: map[string][]string{ + "Accept-Language": {"en-US, en-gb;q=0.8, en;q=0.7"}, + "Expected-Results": {"Something is wrong on our side, please try again."}, + }, + URL: url, + } + req3 := &http.Request{ + Method: "GET", + Header: map[string][]string{ + "Accept-Language": {"de-at, en-gb;q=0.8, en;q=0.7"}, + "Expected-Results": {"Stimmt etwas nicht auf unserer Seite ist, versuchen Sie es erneut."}, + }, + URL: url, + } + + http.DefaultClient.Do(req1) + http.DefaultClient.Do(req2) + http.DefaultClient.Do(req3) +} diff --git a/i18n/locale.go b/i18n/locale.go new file mode 100644 index 0000000..6b4a793 --- /dev/null +++ b/i18n/locale.go @@ -0,0 +1,101 @@ +package i18n + +import ( + "errors" + "net/http" + "strconv" + "strings" +) + +// Locale is locale value from the Accept-Language header in request +type Locale struct { + Lang, Country string + Qual float64 +} + +// Name returns the locale value in 'lang' or 'lang_country' format +// eg: de_DE, en_US, gb +func (l *Locale) Name() string { + if len(l.Country) > 0 { + return l.Lang + "_" + l.Country + } + return l.Lang +} + +// ParseLocale creates a Locale from a locale string +func ParseLocale(locale string) Locale { + locsplt := strings.Split(locale, "_") + resp := Locale{} + resp.Lang = locsplt[0] + if len(locsplt) > 1 { + resp.Country = locsplt[1] + } + return resp +} + +const ( + acceptLanguage = "Accept-Language" +) + +func supportedLocales(alstr string) []Locale { + locales := make([]Locale, 0) + alstr = strings.Replace(alstr, " ", "", -1) + if alstr == "" { + return locales + } + al := strings.Split(alstr, ",") + for _, lstr := range al { + locales = append(locales, Locale{ + Lang: parseLang(lstr), + Country: parseCountry(lstr), + Qual: parseQual(lstr), + }) + } + return locales +} + +// GetLocales returns supported locales for the given requet +func GetLocales(r *http.Request) []Locale { + return supportedLocales(r.Header.Get(acceptLanguage)) +} + +// GetPreferredLocale return preferred locale for the given reuqest +// returns error if there is no preferred locale +func GetPreferredLocale(r *http.Request) (*Locale, error) { + locales := GetLocales(r) + if len(locales) == 0 { + return &Locale{}, errors.New("No locale found") + } + return &locales[0], nil +} + +func parseLang(val string) string { + locale := strings.Split(val, ";")[0] + lang := strings.Split(locale, "-")[0] + return lang +} + +func parseCountry(val string) string { + locale := strings.Split(val, ";")[0] + spl := strings.Split(locale, "-") + if len(spl) > 1 { + return spl[1] + } + return "" +} + +func parseQual(val string) float64 { + spl := strings.Split(val, ";") + if len(spl) > 1 { + qualSpl := strings.Split(spl[1], "=") + if len(qualSpl) > 1 { + qual, err := strconv.ParseFloat(qualSpl[1], 64) + if err != nil { + return 1 + } + return qual + } + } + return 1 +} + diff --git a/i18n/locale_test.go b/i18n/locale_test.go new file mode 100644 index 0000000..6887064 --- /dev/null +++ b/i18n/locale_test.go @@ -0,0 +1,39 @@ +package i18n + +import ( + "testing" +) + +func TestLocale(t *testing.T) { + l0 := supportedLocales("ja-JP;q") + if len(l0) != 1 { + t.Errorf("Expected number of locales \"1\", got %d", len(l0)) + t.Fail() + } + 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") + if len(l1) != 6 { + t.Errorf("Expected number of locales \"6\", got %d", len(l1)) + t.Fail() + } + l2 := supportedLocales("en") + if len(l2) != 1 { + t.Errorf("Expected number of locales \"1\", got %d", len(l2)) + t.Fail() + } + l3 := supportedLocales("") + if len(l3) != 0 { + t.Errorf("Expected number of locales \"0\", got %d", len(l3)) + t.Fail() + } + l4 := ParseLocale("en_US") + if l4.Lang != "en" || l4.Country != "US" { + t.Errorf("Expected \"en\" and \"US\", got %s and %s", l4.Lang, l4.Country) + t.Fail() + } + l5 := ParseLocale("en") + if l5.Lang != "en" || l5.Country != "" { + t.Errorf("Expected \"en\" and \"\", got %s and %s", l5.Lang, l5.Country) + t.Fail() + } + +} diff --git a/i18n/translations/de_DE.json b/i18n/translations/de_DE.json new file mode 100644 index 0000000..c12186d --- /dev/null +++ b/i18n/translations/de_DE.json @@ -0,0 +1,32 @@ +{ + "main.title" : "CutleryPlus", + "main.subtitle" : "Messer, die legte in Besteck geschnitten.", + "menu" : { + "home" : "Home", + "products": { + "self": "Produkte", + "forks" : "Gabeln", + "knives" : "Messer", + "spoons" : "Löffel" + }, + "gallery" : "Galerie", + "about" : "Über uns", + "contact" : "Kontakt" + }, + "home" : { + "title": "Willkommen in CutleryPlus!", + "text" : { + "p1": "Lorem ipsum...", + "p2": "Ein weiterer ipsum lorem." + } + }, + "error" : { + "404" : "Seite {0} wurde nicht gefunden.", + "500" : "Stimmt etwas nicht auf unserer Seite ist, versuchen Sie es erneut.", + "contact" : { + "name" : "Sie müssen Ihren Namen eingeben.", + "email" : "Sie müssen Ihre E-Mail ein.", + "text" : "Sie können eine leere Nachricht nicht zu senden." + } + } +}
\ No newline at end of file diff --git a/i18n/translations/en_US.json b/i18n/translations/en_US.json new file mode 100644 index 0000000..6fa0b8f --- /dev/null +++ b/i18n/translations/en_US.json @@ -0,0 +1,32 @@ +{ + "main.title" : "CutleryPlus", + "main.subtitle" : "Knives that put cut in cutlery.", + "menu" : { + "home" : "Home", + "products": { + "self": "Products", + "forks" : "Forks", + "knives" : "Knives", + "spoons" : "Spoons" + }, + "gallery" : "Gallery", + "about" : "About us", + "contact" : "Contact" + }, + "home" : { + "title": "Welcome to CutleryPlus!", + "text" : { + "p1": "Lorem ipsum...", + "p2": "Another ipsum lorem." + } + }, + "error" : { + "404" : "Page {0} not found!", + "500" : "Something is wrong on our side, please try again.", + "contact" : { + "name" : "You must enter your name.", + "email" : "You must enter your email.", + "text" : "You cannot send an empty message." + } + } +}
\ No newline at end of file diff --git a/i18n/translations/sr_RS.json b/i18n/translations/sr_RS.json new file mode 100644 index 0000000..fe2bea5 --- /dev/null +++ b/i18n/translations/sr_RS.json @@ -0,0 +1,32 @@ +{ + "main.title" : "CutleryPlus", + "main.subtitle" : "Escajg za svakoga", + "menu" : { + "home" : "Pocetna", + "products": { + "self": "Proizvodi", + "forks" : "Viljuske", + "knives" : "Nozevi", + "spoons" : "Kasike" + }, + "gallery" : "Galerija", + "about" : "O nama", + "contact" : "Kontakt" + }, + "home" : { + "title": "Dobrodosli u CutleryPlus!", + "text" : { + "p1": "Lorem ipsum...", + "p2": "Jos jedan ipsum lorem." + } + }, + "error" : { + "404" : "Stranica {0} ne postoji.", + "500" : "Greska sa nase strane, pokusajte ponovo.", + "contact" : { + "name" : "Ime ne sme biti prazno.", + "email" : "Email ne sme biti prazan.", + "text" : "Ne mozete poslati praznu poruku." + } + } +}
\ No newline at end of file |
