├── testdata └── basic │ ├── basic-de_AT.tr │ └── basic-de.tr ├── hellopolyglot ├── l10n │ └── hellopolyglot-de.tr └── main.go ├── AUTHORS ├── README.md ├── LICENSE ├── polyglot_test.go ├── polyglot └── main.go └── polyglot.go /testdata/basic/basic-de_AT.tr: -------------------------------------------------------------------------------- 1 | {"Messages":[{"Locations":[{"File":"../main.go","Line":"789"}],"Source":"Apricot","Context":null,"Translation":"Marille"}]} 2 | -------------------------------------------------------------------------------- /hellopolyglot/l10n/hellopolyglot-de.tr: -------------------------------------------------------------------------------- 1 | {"Messages":[{"Locations":[{"File":"../main.go","Line":"30"}],"Source":"Hello","Context":null,"Translation":"Hallo"}]} 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the official list of 'polyglot' authors for copyright purposes. 2 | 3 | # Names should be added to this file as 4 | # Name or Organization 5 | # The email address is not required for organizations. 6 | 7 | # Please keep the list sorted. 8 | 9 | # Contributors 10 | # ============ 11 | 12 | Alexander Neumann 13 | -------------------------------------------------------------------------------- /testdata/basic/basic-de.tr: -------------------------------------------------------------------------------- 1 | {"Messages":[{"Locations":[{"File":"../main.go","Line":"30"}],"Source":"Hello","Context":null,"Translation":"Hallo"},{"Locations":[{"File":"../main.go","Line":"123"}],"Source":"Exit","Context":["noun"],"Translation":"Ausgang"},{"Locations":[{"File":"../main.go","Line":"456"}],"Source":"Exit","Context":["menu"],"Translation":"Beenden"},{"Locations":[{"File":"../main.go","Line":"789"}],"Source":"Apricot","Context":null,"Translation":"Aprikose"}]} 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | About polyglot 2 | ============== 3 | 4 | polyglot is a String translation package and tool for Go. 5 | 6 | Setup 7 | ===== 8 | 9 | Make sure you have a working Go installation. 10 | See [Getting Started](http://golang.org/doc/install.html) 11 | 12 | Now run `go get github.com/lxn/polyglot` and 13 | `go get github.com/lxn/polyglot/polyglot`. 14 | 15 | How does it work? 16 | ================= 17 | 18 | 1. It's pretty simple. Wrap translatable strings in your code in a call to a 19 | `func tr(source string, context ...string) string`, e.g. `tr("bla")`. You 20 | have to provide this function for every package you wish to use polyglot 21 | from. 22 | 2. After adding new translatable strings to your code, run the polyglot command, 23 | which scans your Go code for calls to a `tr` function. It will create or 24 | update JSON .tr files, adding new translatable strings that it finds. 25 | 3. Translate the strings. 26 | 27 | Please see the hellopolyglot example for more details. -------------------------------------------------------------------------------- /hellopolyglot/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2012 The polyglot Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | ) 11 | 12 | import ( 13 | "github.com/lxn/polyglot" 14 | ) 15 | 16 | var trDict *polyglot.Dict 17 | 18 | // An implemententation of a "tr" function like this is required for every 19 | // package that you want to work with polyglot. 20 | func tr(source string, context ...string) string { 21 | return trDict.Translation(source, context...) 22 | } 23 | 24 | // The example translation file was generated by executing 25 | // cd l10n 26 | // polyglot -dir=".." -locales="de" -name="hellopolyglot" 27 | func main() { 28 | var err error 29 | if trDict, err = polyglot.NewDict("l10n", "de"); err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | // The polyglot command scans Go code for calls to a "tr" function like this 34 | // one. If context args are provided, they are used for disambiguation. 35 | fmt.Println(tr("Hello")) 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 The polyglot Authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions 5 | are met: 6 | 1. Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | 3. The names of the authors may not be used to endorse or promote products 12 | derived from this software without specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR 15 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 16 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 17 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY DIRECT, INDIRECT, 18 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 19 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 20 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 21 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 23 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /polyglot_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2012 The polyglot Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package polyglot 6 | 7 | import ( 8 | "testing" 9 | ) 10 | 11 | func Test_NewDict(t *testing.T) { 12 | type test struct { 13 | locale string 14 | expValidDict bool 15 | expErr error 16 | } 17 | 18 | data := []test{ 19 | {"de", true, nil}, 20 | {"fr_FR", true, nil}, 21 | {"EN", false, ErrInvalidLocale}, 22 | {"EN_US", false, ErrInvalidLocale}, 23 | {"_IT", false, ErrInvalidLocale}, 24 | {"it_", false, ErrInvalidLocale}, 25 | {"1de", false, ErrInvalidLocale}, 26 | {"de_DE2", false, ErrInvalidLocale}, 27 | {"_", false, ErrInvalidLocale}, 28 | {"de-DE", false, ErrInvalidLocale}, 29 | } 30 | 31 | for _, d := range data { 32 | dict, err := NewDict("testdata/basic", d.locale) 33 | 34 | if dict == nil && d.expValidDict { 35 | t.Errorf("Expected valid dict for locale '%s'", d.locale) 36 | } 37 | 38 | if err != d.expErr { 39 | if err == nil { 40 | t.Errorf("Expected '%v' error for locale '%s', but got nil", d.expErr, d.locale) 41 | } else if d.expErr == nil { 42 | t.Errorf("Expected no error for locale '%s', but got '%v'", d.locale, err) 43 | } else { 44 | t.Errorf("Expected '%v' error for locale '%s', but got '%v'", d.expErr, d.locale, err) 45 | } 46 | } 47 | } 48 | } 49 | 50 | func Test_Translation(t *testing.T) { 51 | type test struct { 52 | locale string 53 | source string 54 | context []string 55 | expTrans string 56 | } 57 | 58 | data := []test{ 59 | {"de", "Hello", nil, "Hallo"}, 60 | {"de_DE", "Hello", nil, "Hallo"}, 61 | {"es", "Hello", nil, "Hello"}, 62 | {"de", "Hello", []string{"blah"}, "Hello"}, 63 | {"de_DE", "Hello", []string{"blah"}, "Hello"}, 64 | {"de", "Exit", []string{"noun"}, "Ausgang"}, 65 | {"de_DE", "Exit", []string{"noun"}, "Ausgang"}, 66 | {"de", "Exit", []string{"menu"}, "Beenden"}, 67 | {"de_DE", "Exit", []string{"menu"}, "Beenden"}, 68 | {"en", "Exit", []string{"menu"}, "Exit"}, 69 | {"fr", "Exit", []string{"menu"}, "Exit"}, 70 | {"it_IT", "Exit", []string{"menu"}, "Exit"}, 71 | {"de", "Apricot", nil, "Aprikose"}, 72 | {"de_AT", "Apricot", nil, "Marille"}, 73 | {"de_DE", "Apricot", nil, "Aprikose"}, 74 | } 75 | 76 | for _, d := range data { 77 | dict, err := NewDict("testdata/basic", d.locale) 78 | 79 | if err != nil { 80 | t.Errorf("NewDict failed for locale '%s' with error %s", d.locale, err) 81 | } 82 | 83 | trans := dict.Translation(d.source, d.context...) 84 | 85 | if trans != d.expTrans { 86 | t.Errorf("Expected '%s' for locale '%s' and source '%s', but got '%s'", d.expTrans, d.locale, d.source, trans) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /polyglot/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2012 The polyglot Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "encoding/json" 9 | "flag" 10 | "fmt" 11 | "go/ast" 12 | "go/parser" 13 | "go/token" 14 | "log" 15 | "os" 16 | "path" 17 | "runtime/debug" 18 | "strings" 19 | ) 20 | 21 | var baseName *string = flag.String("name", "", "The base name to use for translation files.") 22 | var directoryPath *string = flag.String("dir", "", "The directory path where to recursively search for Go files.") 23 | var locales *string = flag.String("locales", "", `Comma-separated list of locales, for which to generate or update tr files. e.g.: "de_AT,de_DE,de,es,fr,it".`) 24 | 25 | func logFatal(err error) { 26 | log.Fatalf(`An error occurred: %s 27 | 28 | Stack: 29 | %s`, 30 | err, debug.Stack()) 31 | } 32 | 33 | type Location struct { 34 | File string 35 | Line string 36 | } 37 | 38 | type Message struct { 39 | Locations []*Location 40 | Source string 41 | Context []string 42 | Translation string 43 | } 44 | 45 | type TRFile struct { 46 | Messages []*Message 47 | } 48 | 49 | func sourceKey(source string, context []string) string { 50 | if len(context) == 0 { 51 | return source 52 | } 53 | 54 | return fmt.Sprintf("__%s__%s__", source, strings.Join(context, "__")) 55 | } 56 | 57 | type visitor struct { 58 | fileSet *token.FileSet 59 | sourceKey2Message map[string]*Message 60 | } 61 | 62 | func (v visitor) Visit(node ast.Node) (w ast.Visitor) { 63 | if callExpr, ok := node.(*ast.CallExpr); ok { 64 | if ident, ok := callExpr.Fun.(*ast.Ident); !ok || ident.Name != "tr" { 65 | return v 66 | } 67 | 68 | if len(callExpr.Args) > 0 { 69 | if basicLit, ok := callExpr.Args[0].(*ast.BasicLit); ok { 70 | pos := v.fileSet.Position(callExpr.Pos()) 71 | 72 | source := string(basicLit.Value[1 : len(basicLit.Value)-1]) 73 | var context []string 74 | for _, arg := range callExpr.Args[1:] { 75 | if basicLit, ok := arg.(*ast.BasicLit); ok { 76 | c := string(basicLit.Value[1 : len(basicLit.Value)-1]) 77 | context = append(context, c) 78 | } 79 | } 80 | srcKey := sourceKey(source, context) 81 | message, ok := v.sourceKey2Message[srcKey] 82 | if !ok { 83 | message = &Message{Source: source, Context: context} 84 | v.sourceKey2Message[srcKey] = message 85 | } 86 | 87 | location := &Location{File: pos.Filename, Line: fmt.Sprintf("%d", pos.Line)} 88 | message.Locations = append(message.Locations, location) 89 | } 90 | } 91 | } 92 | 93 | return v 94 | } 95 | 96 | func (v visitor) scanDir(dirPath string) { 97 | dir, err := os.Open(dirPath) 98 | if err != nil { 99 | logFatal(err) 100 | } 101 | defer dir.Close() 102 | 103 | names, err := dir.Readdirnames(-1) 104 | if err != nil { 105 | logFatal(err) 106 | } 107 | 108 | for _, name := range names { 109 | fullPath := path.Join(dirPath, name) 110 | 111 | fi, err := os.Stat(fullPath) 112 | if err != nil { 113 | logFatal(err) 114 | } 115 | 116 | if fi.IsDir() { 117 | v.scanDir(fullPath) 118 | } else if !fi.IsDir() && strings.HasSuffix(fullPath, ".go") { 119 | astFile, err := parser.ParseFile(v.fileSet, fullPath, nil, 0) 120 | if err != nil { 121 | logFatal(err) 122 | } 123 | 124 | ast.Walk(v, astFile) 125 | } 126 | } 127 | } 128 | 129 | func readOldSourceKey2MessageFromTRFile(filePath string) map[string]*Message { 130 | sk2m := make(map[string]*Message) 131 | 132 | if fi, _ := os.Stat(filePath); fi == nil { 133 | return sk2m 134 | } 135 | 136 | file, err := os.Open(filePath) 137 | if err != nil { 138 | logFatal(err) 139 | } 140 | defer file.Close() 141 | 142 | var trf TRFile 143 | if err := json.NewDecoder(file).Decode(&trf); err != nil { 144 | logFatal(err) 145 | } 146 | 147 | for _, msg := range trf.Messages { 148 | sk2m[sourceKey(msg.Source, msg.Context)] = msg 149 | } 150 | 151 | return sk2m 152 | } 153 | 154 | func writeTRFile(filePath string, sourceKey2Message, oldSourceKey2Message map[string]*Message, loc string) { 155 | file, err := os.Create(filePath) 156 | if err != nil { 157 | logFatal(err) 158 | } 159 | defer file.Close() 160 | 161 | var trf TRFile 162 | for _, msg := range sourceKey2Message { 163 | if oldMsg, ok := oldSourceKey2Message[sourceKey(msg.Source, msg.Context)]; ok { 164 | msg.Translation = oldMsg.Translation 165 | } 166 | 167 | trf.Messages = append(trf.Messages, msg) 168 | } 169 | 170 | if err := json.NewEncoder(file).Encode(trf); err != nil { 171 | logFatal(err) 172 | } 173 | } 174 | 175 | func main() { 176 | flag.Parse() 177 | 178 | if *baseName == "" || *directoryPath == "" || *locales == "" { 179 | flag.Usage() 180 | os.Exit(1) 181 | } 182 | 183 | v := visitor{ 184 | fileSet: token.NewFileSet(), 185 | sourceKey2Message: make(map[string]*Message), 186 | } 187 | 188 | v.scanDir(*directoryPath) 189 | 190 | locs := strings.Split(*locales, ",") 191 | for _, loc := range locs { 192 | loc = strings.TrimSpace(loc) 193 | 194 | filePath := fmt.Sprintf("%s-%s.tr", *baseName, loc) 195 | 196 | oldSourceKey2Message := readOldSourceKey2MessageFromTRFile(filePath) 197 | writeTRFile(filePath, v.sourceKey2Message, oldSourceKey2Message, loc) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /polyglot.go: -------------------------------------------------------------------------------- 1 | // Copyright 2012 The polyglot Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package polyglot provides a simple string translation mechanism. 6 | package polyglot 7 | 8 | import ( 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "os" 14 | "path" 15 | "strings" 16 | ) 17 | 18 | var ( 19 | // ErrInvalidLocale is returned if a specified locale is invalid. 20 | ErrInvalidLocale = errors.New("invalid locale") 21 | ) 22 | 23 | type message struct { 24 | Source string 25 | Context []string 26 | Translation string 27 | } 28 | 29 | type trfile struct { 30 | Messages []*message 31 | } 32 | 33 | // Dict provides translated strings appropriate for a specific locale. 34 | type Dict struct { 35 | dirPath string 36 | locales []string 37 | locale2SourceKey2Trans map[string]map[string]string 38 | } 39 | 40 | // NewDict returns a new Dict with the specified translations directory path and 41 | // locale. 42 | // 43 | // The directory will be scanned recursively for JSON encoded .tr translation 44 | // files, as created by the polyglot tool, that have a name suffix matching one 45 | // of the locales in the locale chain. 46 | // Example: Locale "en_US" has chain ["en_US", "en"], so files like 47 | // foo-en_US.tr, foo-en.tr, bar-en.tr, baz-en.tr would be picked up. 48 | func NewDict(translationsDirPath, locale string) (*Dict, error) { 49 | locales := localesChainForLocale(locale) 50 | if len(locales) == 0 { 51 | return nil, ErrInvalidLocale 52 | } 53 | 54 | d := &Dict{ 55 | dirPath: translationsDirPath, 56 | locales: locales, 57 | locale2SourceKey2Trans: make(map[string]map[string]string), 58 | } 59 | 60 | if err := d.loadTranslations(translationsDirPath); err != nil { 61 | return nil, err 62 | } 63 | 64 | return d, nil 65 | } 66 | 67 | // DirPath returns the translations directory path of the Dict. 68 | func (d *Dict) DirPath() string { 69 | return d.dirPath 70 | } 71 | 72 | // Locale returns the locale of the Dict. 73 | func (d *Dict) Locale() string { 74 | return d.locales[0] 75 | } 76 | 77 | // Translation returns a translation of the source string to the locale of the 78 | // Dict or the source string, if no matching translation was found. 79 | // 80 | // Provided context arguments are used for disambiguation. 81 | func (d *Dict) Translation(source string, context ...string) string { 82 | for _, locale := range d.locales { 83 | if sourceKey2Trans, ok := d.locale2SourceKey2Trans[locale]; ok { 84 | if trans, ok := sourceKey2Trans[sourceKey(source, context)]; ok { 85 | return trans 86 | } 87 | } 88 | } 89 | 90 | return source 91 | } 92 | 93 | func (d *Dict) loadTranslation(reader io.Reader, locale string) error { 94 | var trf trfile 95 | 96 | if err := json.NewDecoder(reader).Decode(&trf); err != nil { 97 | return err 98 | } 99 | 100 | sourceKey2Trans, ok := d.locale2SourceKey2Trans[locale] 101 | if !ok { 102 | sourceKey2Trans = make(map[string]string) 103 | 104 | d.locale2SourceKey2Trans[locale] = sourceKey2Trans 105 | } 106 | 107 | for _, m := range trf.Messages { 108 | if m.Translation != "" { 109 | sourceKey2Trans[sourceKey(m.Source, m.Context)] = m.Translation 110 | } 111 | } 112 | 113 | return nil 114 | } 115 | 116 | func (d *Dict) loadTranslations(dirPath string) error { 117 | dir, err := os.Open(dirPath) 118 | if err != nil { 119 | return err 120 | } 121 | defer dir.Close() 122 | 123 | names, err := dir.Readdirnames(-1) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | for _, name := range names { 129 | fullPath := path.Join(dirPath, name) 130 | 131 | fi, err := os.Stat(fullPath) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | if fi.IsDir() { 137 | if err := d.loadTranslations(fullPath); err != nil { 138 | return err 139 | } 140 | } else if locale := d.matchingLocaleFromFileName(name); locale != "" { 141 | file, err := os.Open(fullPath) 142 | if err != nil { 143 | return err 144 | } 145 | defer file.Close() 146 | 147 | if err := d.loadTranslation(file, locale); err != nil { 148 | return err 149 | } 150 | } 151 | } 152 | 153 | return nil 154 | } 155 | 156 | func (d *Dict) matchingLocaleFromFileName(name string) string { 157 | for _, locale := range d.locales { 158 | if strings.HasSuffix(name, fmt.Sprintf("-%s.tr", locale)) { 159 | return locale 160 | } 161 | } 162 | 163 | return "" 164 | } 165 | 166 | func sourceKey(source string, context []string) string { 167 | if len(context) == 0 { 168 | return source 169 | } 170 | 171 | return fmt.Sprintf("__%s__%s__", source, strings.Join(context, "__")) 172 | } 173 | 174 | func localesChainForLocale(locale string) []string { 175 | parts := strings.Split(locale, "_") 176 | if len(parts) > 2 { 177 | return nil 178 | } 179 | 180 | if len(parts[0]) != 2 { 181 | return nil 182 | } 183 | 184 | for _, r := range parts[0] { 185 | if r < rune('a') || r > rune('z') { 186 | return nil 187 | } 188 | } 189 | 190 | if len(parts) == 1 { 191 | return []string{parts[0]} 192 | } 193 | 194 | if len(parts[1]) < 2 || len(parts[1]) > 3 { 195 | return nil 196 | } 197 | 198 | for _, r := range parts[1] { 199 | if r < rune('A') || r > rune('Z') { 200 | return nil 201 | } 202 | } 203 | 204 | return []string{locale, parts[0]} 205 | } 206 | --------------------------------------------------------------------------------