diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index b78abbd..acd6ef8 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/setup-go@v4 with: - go-version: "1.19" + go-version: "1.21" cache: false - name: Check out code diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 7038274..3ba7a95 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,7 +9,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v1 with: - go-version: "1.19" + go-version: "1.21" id: go - name: Check out code diff --git a/README.md b/README.md index f152d48..91c1cdd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ Key Features: - Supports Go's embed FS to load data from inside binary. - Short method names and usage to Gettext like `i18n.T()` or `i18n.N()`. - Support for simple interpolation using keys, e.g. `Some %{key} text` -- Built in support for [templ templating](https://templ.guide/) which uses context throughout. ## Usage @@ -73,19 +72,19 @@ Getting translations is straightforward, you have two options: 1. call methods defined in the package with the context, or, 2. extract the locale from the context and use. -To translate without extracting the locale, you'll need to load the `i18n` helper package: +To translate without extracting the locale, you'll need to load the `i18n` package which contains all the structures and methods used by the main `ctxi18n` without any globals: ```go import "github.com/invopop/ctxi18n/i18n" ``` -This package contains helper methods that allow you to use the context directly: +Then use it with the context: ```go fmt.Println(i18n.T(ctx, "welcome.title")) ``` -Notice in the example that the `title` was previously defined inside the `welcome` object in the source YAML, and we're accessing it here by defining the path `welcome.title`. +Notice in the example that `title` was previously defined inside the `welcome` object in the source YAML, and we're accessing it here by defining the path `welcome.title`. To use the `Locale` object directly, extract it from the context and call the methods: @@ -139,7 +138,7 @@ en: other: "You have %{count} emails. ``` -The `inbox.emails` tag has a sub-object that defines all the translations we need according to the pluralization rules of the language. In the case of English and the default rule set, `zero` is an optional definition that will be used if provided. +The `inbox.emails` tag has a sub-object that defines all the translations we need according to the pluralization rules of the language. In the case of English which uses the default rule set, `zero` is an optional definition that will be used if provided and fallback on `other` if not. To use these translations, call the `i18n.N` method: @@ -150,4 +149,32 @@ fmt.Println(i18n.N(ctx, "inbox.emails", count, i18n.M{"count": count})) The output from this will be: "You have 2 emails." -In the current implementation of `ctxi18n` there are very few pluralization rules defined, please do submit PRs if you language is not covered! +In the current implementation of `ctxi18n` there are very few pluralization rules defined, please submit PRs if your language is not covered! + +## 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. + +The following "Hello World" example is taken from the [Templ Guide](https://templ.guide) and shows how you can quickly add translations the leverage the built-in `ctx` variable provided by Templ. + +```yaml +en: + welcome: + hello: "Hello, %{name}" +``` + +```go +package main + +import "github.com/invopop/ctxi18n/i18n" + +templ Hello(name string) { +
{ i18n.T(ctx, "welcome.hello", i18n.M{"name": name}) }
+} + +templ Greeting(person Person) { +
+ @Hello(person.Name) +
+} +``` diff --git a/ctxi18n.go b/ctxi18n.go index c051180..fd7740a 100644 --- a/ctxi18n.go +++ b/ctxi18n.go @@ -26,13 +26,28 @@ var ( ErrMissingLocale = errors.New("locale not defined") ) +func init() { + locales = new(i18n.Locales) +} + // Load walks through all the files in provided File System and prepares // an internal global list of locales ready to use. func Load(fs fs.FS) error { - locales = new(i18n.Locales) return locales.Load(fs) } +// Get provides the Locale object for the matching code. +func Get(code i18n.Code) *i18n.Locale { + return locales.Get(code) +} + +// Match attempts to find the best possible matching locale based on the +// locale string provided. The locale string is parsed according to the +// "Accept-Language" header format defined in RFC9110. +func Match(locale string) *i18n.Locale { + return locales.Match(locale) +} + // WithLocale tries to match the provided code with a locale and ensures // it is available inside the context. func WithLocale(ctx context.Context, locale string) (context.Context, error) { diff --git a/ctxi18n_test.go b/ctxi18n_test.go new file mode 100644 index 0000000..aca987d --- /dev/null +++ b/ctxi18n_test.go @@ -0,0 +1,72 @@ +package ctxi18n_test + +import ( + "context" + "testing" + + "github.com/invopop/ctxi18n" + "github.com/invopop/ctxi18n/i18n" + "github.com/invopop/ctxi18n/internal/examples" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaults(t *testing.T) { + assert.Equal(t, i18n.Code("en"), ctxi18n.DefaultLocale) +} + +func TestLoad(t *testing.T) { + err := ctxi18n.Load(examples.Content) + assert.NoError(t, err) + + l := ctxi18n.Get("en") + assert.NotNil(t, l) + assert.Equal(t, "en", l.Code().String()) +} + +func TestGet(t *testing.T) { + err := ctxi18n.Load(examples.Content) + assert.NoError(t, err) + + l := ctxi18n.Get("en") + assert.NotNil(t, l) + assert.Equal(t, "en", l.Code().String()) + + l = ctxi18n.Get("bad") + assert.Nil(t, l) +} + +func TestMatch(t *testing.T) { + err := ctxi18n.Load(examples.Content) + require.NoError(t, err) + + l := ctxi18n.Match("en-US,en;q=0.9,es;q=0.8") + assert.NotNil(t, l) + assert.Equal(t, "en", l.Code().String()) +} + +func TestWithLocale(t *testing.T) { + err := ctxi18n.Load(examples.Content) + require.NoError(t, err) + + ctx := context.Background() + ctx, err = ctxi18n.WithLocale(ctx, "en-US,en;q=0.9,es;q=0.8") + require.NoError(t, err) + + l := ctxi18n.Locale(ctx) + assert.NotNil(t, l) + assert.Equal(t, "en", l.Code().String()) + + // Use the default locale if not set + ctx, err = ctxi18n.WithLocale(ctx, "inv") + assert.NoError(t, err) + l = ctxi18n.Locale(ctx) + assert.NotNil(t, l) + assert.Equal(t, "en", l.Code().String()) + + ctxi18n.DefaultLocale = "bad" + _, err = ctxi18n.WithLocale(ctx, "inv") + assert.ErrorIs(t, err, ctxi18n.ErrMissingLocale) + ctxi18n.DefaultLocale = "es" + +} diff --git a/i18n/locales_test.go b/i18n/locales_test.go index 65b1854..489e4e7 100644 --- a/i18n/locales_test.go +++ b/i18n/locales_test.go @@ -29,6 +29,10 @@ func TestLocalesLoad(t *testing.T) { require.NotNil(t, l) assert.Equal(t, "en", l.Code().String()) + l = ls.Match("es-ES,es;q=0.9,en;q=0.8") + require.NotNil(t, l) + assert.Equal(t, "es", l.Code().String()) + assert.Nil(t, ls.Match("inv")) } diff --git a/templ/i18n/i18n.templ b/templ/i18n/i18n.templ deleted file mode 100644 index f5a9bf5..0000000 --- a/templ/i18n/i18n.templ +++ /dev/null @@ -1,17 +0,0 @@ -package i18n - -import ( - "github.com/invopop/ctxi18n/i18n" -) - -// T will use the key to find the translation in the current context and -// replace the placeholders with the given arguments. -templ T(key string, args ...any) { - { i18n.T(ctx, key, args...) } -} - -// N will use the key to find the pluralized translation in the current -// context and replace the placeholders with the given arguments. -templ N(key string, n int, args ...any) { - { i18n.N(ctx, key, n, args...) } -} diff --git a/templ/i18n/i18n_templ.go b/templ/i18n/i18n_templ.go deleted file mode 100644 index e32501e..0000000 --- a/templ/i18n/i18n_templ.go +++ /dev/null @@ -1,77 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.2.590 -package i18n - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import "context" -import "io" -import "bytes" - -import ( - "github.com/invopop/ctxi18n/i18n" -) - -// T will use the key to find the translation in the current context and -// replace the placeholders with the given arguments. -func T(key string, args ...any) templ.Component { - return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) - if !templ_7745c5c3_IsBuffer { - templ_7745c5c3_Buffer = templ.GetBuffer() - defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T(ctx, key, args...)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/i18n/i18n.templ`, Line: 9, Col: 28} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !templ_7745c5c3_IsBuffer { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) - } - return templ_7745c5c3_Err - }) -} - -// N will use the key to find the pluralized translation in the current -// context and replace the placeholders with the given arguments. -func N(key string, n int, args ...any) templ.Component { - return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) - if !templ_7745c5c3_IsBuffer { - templ_7745c5c3_Buffer = templ.GetBuffer() - defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var3 := templ.GetChildren(ctx) - if templ_7745c5c3_Var3 == nil { - templ_7745c5c3_Var3 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.N(ctx, key, n, args...)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/i18n/i18n.templ`, Line: 15, Col: 31} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !templ_7745c5c3_IsBuffer { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) - } - return templ_7745c5c3_Err - }) -}