Skip to content

Commit

Permalink
Merge pull request #1 from invopop/ctx-scopes
Browse files Browse the repository at this point in the history
Support for scope inside context
  • Loading branch information
samlown authored Apr 8, 2024
2 parents ab51af7 + 41f6911 commit 1646de2
Show file tree
Hide file tree
Showing 7 changed files with 87 additions and 14 deletions.
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ Go Context Internationalization - translating apps easily.

## Introduction

`ctxi18n` is heavily influenced by the [Ruby on Rails i18n](https://guides.rubyonrails.org/i18n.html) and aims to make internationalization in Go applications just as straightforward.
`ctxi18n` is heavily influenced by [internationalization in Ruby on Rails](https://guides.rubyonrails.org/i18n.html) and aims to make it just as straightforward in Go applications.

As the name suggests, `ctxi18n` focusses on making i18n methods accessible via the application's context. I18n should be as quick and easy to use as possible, so this package provides a set methods with short names and simple parameters.
As the name suggests, `ctxi18n` focusses on making i18n data available inside an application's context instances, but is sufficiently flexible to use directly if needed.

Key Features:

- Loads locale files written in YAML or JSON, like Ruby i18n.
- Loads locale files written in YAML or JSON with a similar structure those in Ruby i18n.
- Makes it easy to add a locale object to the context.
- Supports `fs.FS` to load data.
- Short method names like `i18n.T()` or `i18n.N()`.
Expand Down Expand Up @@ -158,6 +158,17 @@ The output from this will be: "You have 2 emails."

In the current implementation of `ctxi18n` there are very few pluralization rules defined, please submit PRs if your language is not covered!

## Scopes

As your application gets more complex, it can get repetitive having to use the same base keys. To get around this, use the `WithScope` helper method inside a context:

```go
ctx := i18n.WithScope(ctx, "welcome")
i18n.T(ctx, ".title", i18n.M{"name":"Sam"})
```

Anything with the `.` at the beginning will append the scope. You can continue to use any other key in the locale by not using the `.` at the front.

## Templ

[Templ](https://templ.guide/) is a templating library that helps you create components that render fragments of HTML and compose them to create screens, pages, documents or apps.
Expand Down
11 changes: 7 additions & 4 deletions i18n/dict.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ package i18n

import (
"encoding/json"
"fmt"
"strings"
)

const (
// MissingDictKey is provided when a specific entry in the dictionary
// cannot be found.
MissingDictKey = "!(MISSING)"
missingDictOut = "!(MISSING: %s)"
)

// Dict holds the internationalization entries for a specific locale.
Expand Down Expand Up @@ -46,7 +45,7 @@ func (d *Dict) Add(key string, value any) {
func (d *Dict) Get(key string) string {
entry := d.GetEntry(key)
if entry == nil {
return MissingDictKey
return missing(key)
}
return entry.value
}
Expand Down Expand Up @@ -96,3 +95,7 @@ func (d *Dict) UnmarshalJSON(data []byte) error {
d.entries = make(map[string]*Dict)
return json.Unmarshal(data, &d.entries)
}

func missing(key string) string {
return fmt.Sprintf(missingDictOut, key)
}
4 changes: 2 additions & 2 deletions i18n/dict_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestDictUnmarshalJSON(t *testing.T) {
assert.Equal(t, "bar", dict.Get("foo"))
assert.Equal(t, "quux", dict.Get("baz.qux"))
assert.Equal(t, "", dict.Get("baz.plural"))
assert.Equal(t, "!(MISSING)", dict.Get("random"))
assert.Equal(t, "!(MISSING: random)", dict.Get("random"))
}

func TestDictAdd(t *testing.T) {
Expand All @@ -43,7 +43,7 @@ func TestDictAdd(t *testing.T) {
assert.Equal(t, "%s mice", d.Get("plural.other"))

d.Add("bad", 10) // ignore
assert.Equal(t, MissingDictKey, d.Get("bad"))
assert.Equal(t, "!(MISSING: bad)", d.Get("bad"))

d.Add("self", d)
assert.Equal(t, "bar", d.Get("self.foo"))
Expand Down
36 changes: 34 additions & 2 deletions i18n/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import (
"strings"
)

const (
missingLocaleOut = "!(MISSING LOCALE)"
)

type scopeType string

const (
scopeKey scopeType = "scope"
)

// M stands for map and is a simple helper to make it easier to work with
// internationalization maps.
type M map[string]any
Expand All @@ -17,8 +27,9 @@ type M map[string]any
func T(ctx context.Context, key string, args ...any) string {
l := GetLocale(ctx)
if l == nil {
return MissingDictKey
return missingLocaleOut
}
key = ExpandKey(ctx, key)
return l.T(key, args...)
}

Expand All @@ -27,11 +38,32 @@ func T(ctx context.Context, key string, args ...any) string {
func N(ctx context.Context, key string, n int, args ...any) string {
l := GetLocale(ctx)
if l == nil {
return MissingDictKey
return missingLocaleOut
}
key = ExpandKey(ctx, key)
return l.N(key, n, args...)
}

// WithScope is used to add a new scope to the context. To use this,
// use a `.` at the beginning of keys.
func WithScope(ctx context.Context, key string) context.Context {
key = ExpandKey(ctx, key)
return context.WithValue(ctx, scopeKey, key)
}

// ExpandKey extracts the current scope from the context and appends it
// to the start of the provided key.
func ExpandKey(ctx context.Context, key string) string {
if !strings.HasPrefix(key, ".") {
return key
}
scope, ok := ctx.Value(scopeKey).(string)
if !ok {
return key
}
return fmt.Sprintf("%s%s", scope, key)
}

// Replace is used to interpolate the matched keys in the provided
// string with their values in the map.
//
Expand Down
29 changes: 28 additions & 1 deletion i18n/i18n_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package i18n_test

import (
"context"
"encoding/json"
"testing"

"github.com/invopop/ctxi18n/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMReplace(t *testing.T) {
Expand All @@ -23,19 +25,44 @@ func TestT(t *testing.T) {
l := i18n.NewLocale("en", d)
ctx := l.WithContext(context.Background())
assert.Equal(t, "value", i18n.T(ctx, "key"))

ctx = context.Background()
assert.Equal(t, "!(MISSING LOCALE)", i18n.T(ctx, "key"))
}

func TestN(t *testing.T) {
ctx := context.Background()
assert.Equal(t, "!(MISSING LOCALE)", i18n.N(ctx, "key", 1))

d := i18n.NewDict()
d.Add("key", map[string]any{
"zero": "no mice",
"one": "%{count} mouse",
"other": "%{count} mice",
})
l := i18n.NewLocale("en", d)
ctx := l.WithContext(context.Background())
ctx = l.WithContext(context.Background())

assert.Equal(t, "no mice", i18n.N(ctx, "key", 0, i18n.M{"count": 0}))
assert.Equal(t, "1 mouse", i18n.N(ctx, "key", 1, i18n.M{"count": 1}))
assert.Equal(t, "2 mice", i18n.N(ctx, "key", 2, i18n.M{"count": 2}))
}

func TestScopes(t *testing.T) {
in := SampleLocaleData()
l := i18n.NewLocale("en", nil)
require.NoError(t, json.Unmarshal(in, l))

ctx := l.WithContext(context.Background())
ctxScoped := i18n.WithScope(ctx, "baz")

assert.Equal(t, "quux", i18n.T(ctxScoped, ".qux"))
assert.Equal(t, "!(MISSING: baz.bad)", i18n.T(ctxScoped, ".bad"))
assert.Equal(t, "quux", i18n.T(ctx, "baz.qux"))
assert.Equal(t, "!(MISSING: .qux)", i18n.T(ctx, ".qux"))

assert.Equal(t, "no mice", i18n.N(ctxScoped, ".mice", 0, i18n.M{"count": 0}))

ctxScoped = i18n.WithScope(ctxScoped, ".mice")
assert.Equal(t, "no mice", i18n.T(ctxScoped, ".zero"))
}
2 changes: 1 addition & 1 deletion i18n/locale.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (l *Locale) T(key string, args ...any) string {
func (l *Locale) N(key string, n int, args ...any) string {
entry := l.dict.GetEntry(key)
if entry == nil {
return MissingDictKey
return missing(key)
}
return interpolate(l.rule(entry, n), args...)
}
Expand Down
2 changes: 1 addition & 1 deletion i18n/locale_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestLocaleGet(t *testing.T) {
assert.Equal(t, "no mice", l.N("baz.mice", 0, i18n.M{"count": 0}))
assert.Equal(t, "1 mouse", l.N("baz.mice", 1, i18n.M{"count": 1}))
assert.Equal(t, "2 mice", l.N("baz.mice", 2, i18n.M{"count": 2}))
assert.Equal(t, "!(MISSING)", l.N("random", 2))
assert.Equal(t, "!(MISSING: random)", l.N("random", 2))
}

func TestLocaleInterpolate(t *testing.T) {
Expand Down

0 comments on commit 1646de2

Please sign in to comment.