Skip to content

Commit fb2bf28

Browse files
committed
Setup i18n
1 parent 6fc675c commit fb2bf28

20 files changed

+404
-29
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Install tools:
5252
- [Templ](https://templ.guide/): `go install github.com/a-h/templ/cmd/templ@latest`
5353
- [sqlc](https://docs.sqlc.dev/): `brew install sqlc`
5454
- [Dbmate](https://github.com/amacneil/dbmate): `brew install dbmate`
55+
- [goi18n](https://github.com/nicksnyder/go-i18n): `go install github.com/nicksnyder/go-i18n/v2/goi18n@latest`
5556

5657
Install Node and upgrade npm, for example with [mise](https://mise.jdx.dev/lang/node.html):
5758

Taskfile.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,19 @@ tasks:
101101
- task: drop
102102
- task: migrate
103103
- task: seed
104+
105+
i18n-extract:
106+
desc: Extract messages from Go source files to the default `active.en.toml` file
107+
cmds:
108+
- goi18n extract -outdir locales app
109+
110+
i18n-translate:
111+
desc: Generate updated `translate.*.toml` files to be translated
112+
cmds:
113+
- goi18n merge -outdir locales locales/active.*.toml
114+
115+
i18n-merge:
116+
desc: Merge translated messages back into `active.*.toml` files and clean up translation files
117+
cmds:
118+
- goi18n merge -outdir locales locales/active.*.toml locales/translate.*.toml
119+
- rm locales/translate.*.toml

app/app.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import (
1111
"github.com/nicolashery/simply-shared-notes/app/config"
1212
"github.com/nicolashery/simply-shared-notes/app/db"
1313
"github.com/nicolashery/simply-shared-notes/app/email"
14+
"github.com/nicolashery/simply-shared-notes/app/intl"
1415
"github.com/nicolashery/simply-shared-notes/app/server"
1516
"github.com/nicolashery/simply-shared-notes/app/session"
1617
"github.com/nicolashery/simply-shared-notes/app/vite"
1718
)
1819

19-
func Run(ctx context.Context, distFS embed.FS, pragmasSQL string) error {
20+
func Run(ctx context.Context, distFS embed.FS, pragmasSQL string, localesFS embed.FS) error {
2021
ctx, stop := signal.NotifyContext(ctx, os.Interrupt)
2122
defer stop()
2223

@@ -51,7 +52,12 @@ func Run(ctx context.Context, distFS embed.FS, pragmasSQL string) error {
5152
return err
5253
}
5354

54-
s := server.New(cfg, logger, sqlDB, queries, vite, sessionStore, email)
55+
i18nBundle, err := intl.NewBundle(localesFS)
56+
if err != nil {
57+
return err
58+
}
59+
60+
s := server.New(cfg, logger, sqlDB, queries, vite, sessionStore, email, i18nBundle)
5561

5662
return server.Run(ctx, s, logger, cfg.Port)
5763
}

app/handlers/identity.handlers.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"strings"
88

9+
"github.com/nicksnyder/go-i18n/v2/i18n"
910
"github.com/nicolashery/simply-shared-notes/app/db"
1011
"github.com/nicolashery/simply-shared-notes/app/rctx"
1112
"github.com/nicolashery/simply-shared-notes/app/session"
@@ -84,11 +85,23 @@ func handleIdentitySet(logger *slog.Logger, queries *db.Queries) http.HandlerFun
8485
return
8586
}
8687

88+
intl := rctx.GetIntl(r.Context())
89+
msg := intl.Localize(&i18n.LocalizeConfig{
90+
DefaultMessage: &i18n.Message{
91+
ID: "Spaces.Show.Flash",
92+
Other: "{{.Member}}, welcome to the space {{.Space}}!",
93+
},
94+
TemplateData: map[string]any{
95+
"Member": member.Name,
96+
"Space": space.Name,
97+
},
98+
})
99+
87100
sess := rctx.GetSession(r.Context())
88101
sess.Values[session.IdentityKey] = member.ID
89102
sess.AddFlash(session.FlashMessage{
90103
Type: session.FlashType_Info,
91-
Content: fmt.Sprintf("%s, welcome to the space %s!", member.Name, space.Name),
104+
Content: msg,
92105
})
93106

94107
redirectURL := fmt.Sprintf("/s/%s", access.Token)

app/handlers/routes.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/go-chi/chi/v5"
88
"github.com/gorilla/sessions"
9+
"github.com/nicksnyder/go-i18n/v2/i18n"
910
"github.com/nicolashery/simply-shared-notes/app/access"
1011
"github.com/nicolashery/simply-shared-notes/app/config"
1112
"github.com/nicolashery/simply-shared-notes/app/db"
@@ -21,9 +22,11 @@ func RegisterRoutes(
2122
queries *db.Queries,
2223
sessionStore *sessions.CookieStore,
2324
email *email.Email,
25+
i18nBundle *i18n.Bundle,
2426
) {
2527
r.Use(rctx.SessionCtxMiddleware(logger, sessionStore))
2628
r.Use(rctx.ThemeCtxMiddleware())
29+
r.Use(rctx.IntlCtxMiddleware(logger, i18nBundle))
2730

2831
r.Get("/", handleHome(logger))
2932

app/handlers/spaces.handlers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func handleSpacesCreate(cfg *config.Config, logger *slog.Logger, sqlDB *sql.DB,
112112
return
113113
}
114114

115-
emailSubject := emails.SpaceCreatedSubject(space)
115+
emailSubject := emails.SpaceCreatedSubject(r.Context(), space)
116116
baseURL := baseUrlFromRequest(r)
117117
emailText := emails.SpaceCreatedText(member.Name, space, baseURL, tokens)
118118
err = email.Send(space.Email, emailSubject, emailText)

app/intl/intl.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package intl
2+
3+
import (
4+
"embed"
5+
"fmt"
6+
"log/slog"
7+
"strings"
8+
9+
"github.com/BurntSushi/toml"
10+
"github.com/nicksnyder/go-i18n/v2/i18n"
11+
"golang.org/x/text/language"
12+
)
13+
14+
var DefaultLang language.Tag = language.English
15+
16+
var SupportedLangs = []language.Tag{
17+
language.English,
18+
language.French,
19+
}
20+
21+
const LocalizeErrorMessage string = "<failed to localize>"
22+
23+
type Intl struct {
24+
CurrentLang language.Tag
25+
localizer *i18n.Localizer
26+
logger *slog.Logger
27+
}
28+
29+
func NewBundle(localesFS embed.FS) (*i18n.Bundle, error) {
30+
b := i18n.NewBundle(DefaultLang)
31+
b.RegisterUnmarshalFunc("toml", toml.Unmarshal)
32+
33+
for _, tag := range SupportedLangs {
34+
path := fmt.Sprintf("locales/active.%s.toml", tag.String())
35+
// debug: check if path exists in localFS
36+
// if _, err := localeFS.Open(path); err != nil {
37+
// return nil, fmt.Errorf("loading i18n file %q: %w", path, err)
38+
// }
39+
if _, err := b.LoadMessageFileFS(localesFS, path); err != nil {
40+
return nil, fmt.Errorf("loading i18n file %q: %w", path, err)
41+
}
42+
}
43+
44+
return b, nil
45+
}
46+
47+
func New(logger *slog.Logger, i18nBundle *i18n.Bundle, lang language.Tag) *Intl {
48+
localizer := i18n.NewLocalizer(i18nBundle, lang.String())
49+
50+
return &Intl{
51+
CurrentLang: lang,
52+
localizer: localizer,
53+
logger: logger,
54+
}
55+
}
56+
57+
func (i *Intl) Localize(lc *i18n.LocalizeConfig) string {
58+
msg, err := i.localizer.Localize(lc)
59+
if err != nil {
60+
i.logger.Error(
61+
"failed to localize",
62+
slog.Any("error", err),
63+
slog.String("lang", i.CurrentLang.String()),
64+
slog.String("key", lc.MessageID),
65+
)
66+
return LocalizeErrorMessage
67+
}
68+
69+
return msg
70+
}
71+
72+
func (i *Intl) SplitOnSlot(s, slot string) (string, string) {
73+
j := strings.Index(s, slot)
74+
if j < 0 {
75+
return s, ""
76+
}
77+
return s[:j], s[j+len(slot):]
78+
}

app/rctx/context_keys.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const (
66
viteContextKey contextKey = "vite"
77
sessionContextKey contextKey = "session"
88
themeContextKey contextKey = "theme"
9+
intlContextKey contextKey = "intl"
910
spaceContextKey contextKey = "space"
1011
spaceStatsContextKey contextKey = "space_stats"
1112
accessContextKey contextKey = "access"

app/rctx/intl.context.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package rctx
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"net/http"
7+
"slices"
8+
9+
"github.com/nicksnyder/go-i18n/v2/i18n"
10+
"github.com/nicolashery/simply-shared-notes/app/intl"
11+
"golang.org/x/text/language"
12+
)
13+
14+
func IntlCtxMiddleware(logger *slog.Logger, i18nBundle *i18n.Bundle) func(http.Handler) http.Handler {
15+
return func(next http.Handler) http.Handler {
16+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
lang := selectLanguage(r.Header.Get("Accept-Language"))
18+
19+
intl := intl.New(logger, i18nBundle, lang)
20+
21+
ctx := context.WithValue(r.Context(), intlContextKey, intl)
22+
next.ServeHTTP(w, r.WithContext(ctx))
23+
})
24+
}
25+
}
26+
27+
func selectLanguage(acceptLang string) language.Tag {
28+
if acceptLang == "" {
29+
return intl.DefaultLang
30+
}
31+
32+
tags, _, err := language.ParseAcceptLanguage(acceptLang)
33+
if err != nil {
34+
return intl.DefaultLang
35+
}
36+
37+
// Find the first tag that matches one of our supported languages
38+
for _, tag := range tags {
39+
if slices.Contains(intl.SupportedLangs, tag) {
40+
return tag
41+
}
42+
}
43+
44+
return intl.DefaultLang
45+
}
46+
47+
func GetIntl(ctx context.Context) *intl.Intl {
48+
intl, ok := ctx.Value(intlContextKey).(*intl.Intl)
49+
if !ok {
50+
panic("intl not found in context, make sure to use middleware")
51+
}
52+
53+
return intl
54+
}

app/server/server.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/go-chi/chi/v5"
1111
"github.com/go-chi/chi/v5/middleware"
1212
"github.com/gorilla/sessions"
13+
"github.com/nicksnyder/go-i18n/v2/i18n"
1314
"github.com/nicolashery/simply-shared-notes/app/config"
1415
"github.com/nicolashery/simply-shared-notes/app/db"
1516
"github.com/nicolashery/simply-shared-notes/app/email"
@@ -26,6 +27,7 @@ func New(
2627
vite *vite.Vite,
2728
sessionStore *sessions.CookieStore,
2829
email *email.Email,
30+
i18nBundle *i18n.Bundle,
2931
) http.Handler {
3032
router := chi.NewRouter()
3133

@@ -35,7 +37,7 @@ func New(
3537
rctx.ViteCtxMiddleware(vite),
3638
)
3739

38-
handlers.RegisterRoutes(router, cfg, logger, sqlDB, queries, sessionStore, email)
40+
handlers.RegisterRoutes(router, cfg, logger, sqlDB, queries, sessionStore, email, i18nBundle)
3941

4042
StaticDir(router, "/assets", vite.AssetsFS)
4143
StaticFile(router, "/robots.txt", vite.PublicFS)

0 commit comments

Comments
 (0)