diff --git a/.gitignore b/.gitignore index 8de21b9..63baff1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .DS_Store master.key +NOTES.md diff --git a/example/cmd/main.go b/example/cmd/main.go index 3b63608..b55f852 100644 --- a/example/cmd/main.go +++ b/example/cmd/main.go @@ -11,6 +11,7 @@ func main() { app := seatbelt.New(seatbelt.Option{ TemplateDir: "templates", Reload: true, + LocaleDir: "locales", }) app.Use(func(fn func(ctx *seatbelt.Context) error) func(*seatbelt.Context) error { @@ -59,5 +60,9 @@ func main() { return c.Redirect("/session") }) + app.Get("/txt", func(c *seatbelt.Context) error { + return c.String(200, c.I18N.T("Hello", nil)) + }) + log.Fatalln(app.Start(":3000")) } diff --git a/example/locales/active.en.json b/example/locales/active.en.json new file mode 100644 index 0000000..e9ed2b2 --- /dev/null +++ b/example/locales/active.en.json @@ -0,0 +1,14 @@ +{ + "Home": { + "other": "Home" + }, + "Hello": { + "other": "Hello" + }, + "WelcomeMessage": { + "other": "Welcome to Seatbelt. This is a sample application that shows off all the framework can do." + }, + "SessionData": { + "other": "Set and view session data" + } +} diff --git a/example/locales/active.fr.json b/example/locales/active.fr.json new file mode 100644 index 0000000..c2ab05f --- /dev/null +++ b/example/locales/active.fr.json @@ -0,0 +1,14 @@ +{ + "Home": { + "other": "Maison" + }, + "Hello": { + "other": "Salut" + }, + "WelcomeMessage": { + "other": "Bienvenue à Seatbelt. Voici un application qui démontre tout que le framework peut faire." + }, + "SessionData": { + "other": "Voire et définir les données de session" + } +} diff --git a/example/templates/index.html b/example/templates/index.html index d1f1fbf..de0eb97 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -1,4 +1,5 @@ -{{ define "title-index" }}Home{{ end }} -
Welcome to Seatbelt. This is a sample application that shows off all the framework can do.
-Set and view session data +{{ define "title-index" }}{{ t "Home" . }}{{ end }} +{{ t "WelcomeMessage" . }}
+{{ t "SessionData" . }} +Plaintext link diff --git a/go.mod b/go.mod index 12036fc..e79bfb6 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,8 @@ require ( require ( github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/nicksnyder/go-i18n/v2 v2.2.1 // indirect github.com/pkg/errors v0.9.1 // indirect golang.org/x/sys v0.3.0 // indirect + golang.org/x/text v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index 6e1655a..771c093 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= @@ -8,10 +9,40 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nicksnyder/go-i18n/v2 v2.2.1 h1:aOzRCdwsJuoExfZhoiXHy4bjruwCMdt5otbYojM/PaA= +github.com/nicksnyder/go-i18n/v2 v2.2.1/go.mod h1:fF2++lPHlo+/kPaj3nB0uxtPwzlPm+BlgwGX7MkeGj0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/unrolled/render v1.5.0 h1:uNTHMvVoI9pyyXfgoDHHycIqFONNY2p4eQR9ty+NsxM= github.com/unrolled/render v1.5.0/go.mod h1:eLTosBkQqEPEk7pRfkCRApXd++lm++nCsVlFOHpeedw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/i18n/i18n.go b/i18n/i18n.go new file mode 100644 index 0000000..6ebe8af --- /dev/null +++ b/i18n/i18n.go @@ -0,0 +1,97 @@ +package i18n + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" +) + +type Translator struct { + path string + bundle *i18n.Bundle +} + +// New creates a new instance of a translator from the given file path. +func New(path string) *Translator { + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + + // If the path is an empty string, we'll fall back to the default bundle, + // which will output the "translation missing" error for every string. + // Otherwise, load the translation data from the given filepath. + if path != "" { + if err := filepath.Walk(path, func(filepath string, info os.FileInfo, _ error) error { + if info == nil || info.IsDir() { + return nil + } + // TODO Check file extension (or possibly regex of filename) so + // that it doesn't break on unintenionally added files. + _, err := bundle.LoadMessageFile(filepath) + return err + }); err != nil { + panic(err) + } + } + + return &Translator{ + path: path, + bundle: bundle, + } +} + +// T translates the string with the given name. +func (t *Translator) T(r *http.Request, id string, data map[string]interface{}, pluralCount ...int) string { + lang := r.URL.Query().Get("locale") + accept := r.Header.Get("Accept-Language") + + localizer := i18n.NewLocalizer(t.bundle, lang, accept) + + lc := &i18n.LocalizeConfig{ + MessageID: id, + TemplateData: data, + } + for _, pc := range pluralCount { + lc.PluralCount = pc + } + + text, err := localizer.Localize(lc) + if err != nil { + // TODO Consider a "development" switch for this to raise an error or + // something rather than outputting the "translation missing:" + // message. + return "translation missing: " + guessLang(accept, lang) + ", " + id + } + + return text +} + +// TODO Make this work the exact same as i18n.NewLocalizer +func guessLang(langs ...string) string { + defaultLang := language.English.String() + + if len(langs) == 0 { + return defaultLang + } + + var guessedLang string + for i := len(langs) - 1; i != 0; i-- { + if langs[i] == "" { + continue + } + + tag, err := language.Parse(langs[i]) + if err != nil { + continue + } + guessedLang = tag.String() + } + + if guessedLang == "" { + return defaultLang + } + return guessedLang +} diff --git a/i18n/i18n_test.go b/i18n/i18n_test.go new file mode 100644 index 0000000..7ccd09d --- /dev/null +++ b/i18n/i18n_test.go @@ -0,0 +1,23 @@ +package i18n + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestTranslator(t *testing.T) { + translator := New("testdata") + + req := httptest.NewRequest(http.MethodGet, "/?locale=fr", nil) + + s := translator.T(req, "PersonCats", map[string]interface{}{ + "Name": "Ben", + "Count": 0, + }) + + expected := "Ben a 0 chats." + if expected != s { + t.Fatalf("expected %s but got %s", expected, s) + } +} diff --git a/i18n/testdata/active.en.json b/i18n/testdata/active.en.json new file mode 100644 index 0000000..49c2fda --- /dev/null +++ b/i18n/testdata/active.en.json @@ -0,0 +1,6 @@ +{ + "PersonCats": { + "one": "{{.Name}} has {{.Count}} cat.", + "other": "{{.Name}} has {{.Count}} cats." + } +} diff --git a/i18n/testdata/active.fr.json b/i18n/testdata/active.fr.json new file mode 100644 index 0000000..229670a --- /dev/null +++ b/i18n/testdata/active.fr.json @@ -0,0 +1,6 @@ +{ + "PersonCats": { + "one": "{{.Name}} a {{.Count}} chat.", + "other": "{{.Name}} a {{.Count}} chats." + } +} diff --git a/seatbelt.go b/seatbelt.go index 878973f..fdcec77 100644 --- a/seatbelt.go +++ b/seatbelt.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/go-seatbelt/seatbelt/handler" + "github.com/go-seatbelt/seatbelt/i18n" "github.com/go-seatbelt/seatbelt/render" "github.com/go-seatbelt/seatbelt/session" @@ -30,12 +31,23 @@ func ChiPathParamFunc(r *http.Request, values map[string]interface{}) { } } +type ContextI18N struct { + r *http.Request + i18n *i18n.Translator +} + +func (ci *ContextI18N) T(id string, data map[string]any, count ...int) string { + return ci.i18n.T(ci.r, id, data, count...) +} + type Context struct { r *http.Request w http.ResponseWriter session *session.Session renderer *render.Render values map[string]any + + I18N *ContextI18N } func (c *Context) Params(v interface{}) error { @@ -215,9 +227,10 @@ type App struct { // The signing key for the session and CSRF cookies. signingKey []byte - // First party dependencies on the session and render packages. + // First party dependencies on the session, render, and i18n packages. session *session.Session renderer *render.Render + i18n *i18n.Translator // The HTTP router and its configuration options. mux chi.Router @@ -233,6 +246,9 @@ type Option struct { // The directory containing your HTML templates. TemplateDir string + // The directory containing your i18n data. + LocaleDir string + // The signing key for the session cookie store. SigningKey string @@ -298,9 +314,12 @@ func (o *Option) setMasterKey() { // defaultTemplateFuncs sets default HTML template functions on each request // context. -func defaultTemplateFuncs(session *session.Session) func(w http.ResponseWriter, r *http.Request) template.FuncMap { +func defaultTemplateFuncs(session *session.Session, translator *i18n.Translator) func(w http.ResponseWriter, r *http.Request) template.FuncMap { return func(w http.ResponseWriter, r *http.Request) template.FuncMap { return template.FuncMap{ + "t": func(id string, data map[string]interface{}, pluralCount ...int) string { + return translator.T(r, id, data, pluralCount...) + }, "csrf": func() template.HTML { return csrf.TemplateField(r) }, @@ -346,6 +365,8 @@ func New(opts ...Option) *App { log.Fatalf("seatbelt: signing key is not a valid hexadecimal string: %+v", err) } + translator := i18n.New(opt.LocaleDir) + // Initialize the underlying chi mux so that we can setup our default // middleware stack. mux := chi.NewRouter() @@ -356,7 +377,7 @@ func New(opts ...Option) *App { MaxAge: opt.SessionMaxAge, }) - funcMaps := []render.ContextualFuncMap{defaultTemplateFuncs(sess)} + funcMaps := []render.ContextualFuncMap{defaultTemplateFuncs(sess, translator)} if opt.Funcs != nil { funcMaps = append(funcMaps, opt.Funcs) } @@ -371,6 +392,7 @@ func New(opts ...Option) *App { Reload: opt.Reload, Funcs: funcMaps, }), + i18n: translator, } if !opt.SkipServeFiles { @@ -432,7 +454,14 @@ func (a *App) handleErr(c *Context, err error) { // serveContext creates and registers a Seatbelt handler for an HTTP request. func (a *App) serveContext(w http.ResponseWriter, r *http.Request, handle func(c *Context) error) { - c := &Context{w: w, r: r, session: a.session, renderer: a.renderer} + c := &Context{ + w: w, r: r, + session: a.session, renderer: a.renderer, + I18N: &ContextI18N{ + r: r, + i18n: a.i18n, + }, + } // Iterate over the middleware in reverse order, so that the order // in which middleware is registered suggests that it is run from