Skip to content

Commit

Permalink
Merge pull request #11 from go-seatbelt/i18n/add-simple-i18n-funcs
Browse files Browse the repository at this point in the history
i18n: add basic i18n support
  • Loading branch information
bentranter authored Jan 6, 2023
2 parents ef504ff + ea87bee commit 088fadb
Show file tree
Hide file tree
Showing 12 changed files with 237 additions and 8 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
.DS_Store

master.key
NOTES.md
5 changes: 5 additions & 0 deletions example/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"))
}
14 changes: 14 additions & 0 deletions example/locales/active.en.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
14 changes: 14 additions & 0 deletions example/locales/active.fr.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
9 changes: 5 additions & 4 deletions example/templates/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{{ define "title-index" }}Home{{ end }}
<h1 class="title">Hello!</h1>
<p>Welcome to Seatbelt. This is a sample application that shows off all the framework can do.</p>
<a href="/session">Set and view session data</a>
{{ define "title-index" }}{{ t "Home" . }}{{ end }}
<h1 class="title">{{ t "Hello" . }}</h1>
<p>{{ t "WelcomeMessage" . }}</p>
<a href="/session">{{ t "SessionData" . }}</a>
<a href="/txt">Plaintext link</a>
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
31 changes: 31 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
97 changes: 97 additions & 0 deletions i18n/i18n.go
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 23 additions & 0 deletions i18n/i18n_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 6 additions & 0 deletions i18n/testdata/active.en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"PersonCats": {
"one": "{{.Name}} has {{.Count}} cat.",
"other": "{{.Name}} has {{.Count}} cats."
}
}
6 changes: 6 additions & 0 deletions i18n/testdata/active.fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"PersonCats": {
"one": "{{.Name}} a {{.Count}} chat.",
"other": "{{.Name}} a {{.Count}} chats."
}
}
37 changes: 33 additions & 4 deletions seatbelt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
Expand All @@ -371,6 +392,7 @@ func New(opts ...Option) *App {
Reload: opt.Reload,
Funcs: funcMaps,
}),
i18n: translator,
}

if !opt.SkipServeFiles {
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 088fadb

Please sign in to comment.