-
Notifications
You must be signed in to change notification settings - Fork 85
Add i18n framework #1107
Add i18n framework #1107
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
// Copyright 2020 Google LLC | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
// Package i18n defines internationalization and localization. | ||
package i18n | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"io/ioutil" | ||
"path/filepath" | ||
|
||
"github.com/leonelquinteros/gotext" | ||
"golang.org/x/text/language" | ||
|
||
"github.com/google/exposure-notifications-verification-server/internal/project" | ||
) | ||
|
||
const ( | ||
// defaultLocale is the default fallback locale when all else fails. | ||
defaultLocale = "en" | ||
|
||
// defaultDomain is the domain to load. | ||
defaultDomain = "default" | ||
) | ||
|
||
// LocaleMap is a map of locale names to their data structure. | ||
type LocaleMap struct { | ||
data map[string]*gotext.Locale | ||
matcher language.Matcher | ||
} | ||
|
||
// Lookup finds the best locale for the given ids. If none exists, the default | ||
// locale is used. | ||
func (l *LocaleMap) Lookup(ids ...string) *gotext.Locale { | ||
for _, id := range ids { | ||
// Convert the id to the "canonical" form. | ||
canonical, err := l.Canonicalize(id) | ||
if err != nil { | ||
continue | ||
} | ||
locale, ok := l.data[canonical] | ||
if !ok { | ||
continue | ||
} | ||
return locale | ||
} | ||
|
||
return l.data[defaultLocale] | ||
} | ||
|
||
// Canonicalize converts the given ID to the expected name. | ||
func (l *LocaleMap) Canonicalize(id string) (string, error) { | ||
desired, _, err := language.ParseAcceptLanguage(id) | ||
if err != nil { | ||
return "", err | ||
} | ||
if tag, _, conf := l.matcher.Match(desired...); conf != language.No { | ||
raw, _, _ := tag.Raw() | ||
return raw.String(), nil | ||
} | ||
return "", fmt.Errorf("unknown language %q", id) | ||
} | ||
|
||
// Load parses and loads the localization files from disk. It builds the locale | ||
// matcher based on the currently available data (organized by folder). | ||
// | ||
// Due to the heavy I/O, callers should cache the resulting value and only call | ||
// Load when data needs to be refreshed. | ||
func Load(ctx context.Context) (*LocaleMap, error) { | ||
localesDir := filepath.Join(project.Root(), "internal", "i18n", "locales") | ||
|
||
entries, err := ioutil.ReadDir(localesDir) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to load locales: %w", err) | ||
} | ||
|
||
data := make(map[string]*gotext.Locale, len(entries)) | ||
names := make([]language.Tag, 0, len(entries)) | ||
for _, entry := range entries { | ||
name := entry.Name() | ||
names = append(names, language.Make(name)) | ||
|
||
locale := gotext.NewLocale(localesDir, name) | ||
locale.AddDomain(defaultDomain) | ||
data[name] = locale | ||
} | ||
|
||
return &LocaleMap{ | ||
data: data, | ||
matcher: language.NewMatcher(names), | ||
}, nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
// Copyright 2020 Google LLC | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
package i18n | ||
|
||
import ( | ||
"os" | ||
"path/filepath" | ||
"reflect" | ||
"testing" | ||
|
||
"github.com/google/exposure-notifications-verification-server/internal/project" | ||
"github.com/leonelquinteros/gotext" | ||
) | ||
|
||
// TestI18n_matching constructs the superset of all i18n strings and then | ||
// ensures all translation files define said strings. | ||
func TestI18n_matching(t *testing.T) { | ||
t.Parallel() | ||
|
||
var pos []*gotext.Po | ||
localesDir := filepath.Join(project.Root(), "internal", "i18n", "locales") | ||
if err := filepath.Walk(localesDir, func(pth string, info os.FileInfo, err error) error { | ||
if err != nil { | ||
return err | ||
} | ||
|
||
if info.IsDir() { | ||
return nil | ||
} | ||
|
||
if filepath.Ext(info.Name()) != ".po" { | ||
return nil | ||
} | ||
|
||
po := new(gotext.Po) | ||
po.ParseFile(pth) | ||
pos = append(pos, po) | ||
return nil | ||
}); err != nil { | ||
t.Fatal(err) | ||
} | ||
|
||
// This will almost certainly come back to bite me, but the only way to access | ||
// the actual "list" of translations is to access a private field with | ||
// reflect. Please don't try this at home kids. | ||
translations := make(map[string]struct{}) | ||
translationsByLocale := make(map[string]map[string]struct{}) | ||
for _, po := range pos { | ||
keys := reflect.ValueOf(po).Elem().FieldByName("translations").MapKeys() | ||
for _, v := range keys { | ||
if s := v.String(); s != "" { | ||
translations[s] = struct{}{} | ||
|
||
if translationsByLocale[po.Language] == nil { | ||
translationsByLocale[po.Language] = make(map[string]struct{}) | ||
} | ||
translationsByLocale[po.Language][s] = struct{}{} | ||
} | ||
} | ||
} | ||
|
||
for k := range translations { | ||
for loc, existing := range translationsByLocale { | ||
if _, ok := existing[k]; !ok { | ||
t.Errorf("locale %q is missing translation %q", loc, k) | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
msgid "" | ||
msgstr "" | ||
"Language: en\n" | ||
"MIME-Version: 1.0\n" | ||
"Content-Type: text/plain; charset=UTF-8\n" | ||
"Content-Transfer-Encoding: 8bit\n" | ||
"Plural-Forms: nplurals=2; plural=(n != 1);\n" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what is this Plural-Forms doing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing right now. But if you ever had a sentence like:
You'd want to define:
That's what plurals do. It's part of the gettext standard: https://www.gnu.org/software/gettext/manual/html_node/Translating-plural-forms.html |
||
|
||
msgid "create-verification-code" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd like to come up with a better grouping for these. I thought about |
||
msgstr "Create verification code" | ||
|
||
msgid "diagnosis" | ||
msgstr "Diagnosis" | ||
|
||
msgid "dates" | ||
msgstr "Dates" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
msgid "" | ||
msgstr "" | ||
"Language: es\n" | ||
"MIME-Version: 1.0\n" | ||
"Content-Type: text/plain; charset=UTF-8\n" | ||
"Content-Transfer-Encoding: 8bit\n" | ||
"Plural-Forms: nplurals=2; plural=(n != 1);\n" | ||
|
||
msgid "create-verification-code" | ||
msgstr "Crear código de verificación" | ||
|
||
msgid "diagnosis" | ||
msgstr "Diagnóstico" | ||
|
||
msgid "dates" | ||
msgstr "Fechas" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice spanish |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
// Copyright 2020 Google LLC | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
package middleware | ||
|
||
import ( | ||
"net/http" | ||
|
||
"github.com/google/exposure-notifications-verification-server/internal/i18n" | ||
"github.com/google/exposure-notifications-verification-server/pkg/controller" | ||
|
||
"github.com/gorilla/mux" | ||
) | ||
|
||
const ( | ||
HeaderAcceptLanguage = "Accept-Language" | ||
QueryKeyLanguage = "lang" | ||
) | ||
|
||
// ProcessLocale extracts the locale from the various possible locations and | ||
// sets the template translator to the correct language. | ||
// | ||
// This must be called after the template map has been created. | ||
func ProcessLocale(locales *i18n.LocaleMap) mux.MiddlewareFunc { | ||
return func(next http.Handler) http.Handler { | ||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
ctx := r.Context() | ||
|
||
// TODO(sethvargo): extract from session/cookie as well | ||
param := r.URL.Query().Get(QueryKeyLanguage) | ||
cookie := "" | ||
header := r.Header.Get(HeaderAcceptLanguage) | ||
|
||
// Find the "best" language from the given parameters. They are in | ||
// priority order. | ||
m := controller.TemplateMapFromContext(ctx) | ||
m["locale"] = locales.Lookup(param, cookie, header) | ||
|
||
next.ServeHTTP(w, r) | ||
}) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not in love with having to pass in the locale, but template functions aren't mutable after creation.