├── .github
└── workflows
│ ├── lint.yaml
│ ├── release.yaml
│ └── test.yaml
├── .gitignore
├── .golangci.yaml
├── LICENSE
├── README.md
├── ctxi18n.go
├── ctxi18n_test.go
├── go.mod
├── go.sum
├── i18n
├── code.go
├── code_test.go
├── dict.go
├── dict_test.go
├── i18n.go
├── i18n_test.go
├── locale.go
├── locale_test.go
├── locales.go
├── locales_test.go
├── plural_rules.go
└── plural_rules_test.go
└── internal
└── examples
├── en
├── en.yaml
└── ext.json
├── es
└── es.yaml
├── examples.go
└── ignore.txt
/.github/workflows/lint.yaml:
--------------------------------------------------------------------------------
1 | name: Lint
2 | on:
3 | push:
4 | tags:
5 | - v*
6 | branches:
7 | - main
8 | pull_request:
9 | jobs:
10 | lint:
11 | name: golangci-lint
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Check out code
16 | uses: actions/checkout@v3
17 |
18 | - name: Set up Go
19 | uses: actions/setup-go@v5
20 | with:
21 | go-version-file: "go.mod"
22 |
23 | - name: Lint
24 | uses: golangci/golangci-lint-action@v6
25 | with:
26 | version: v1.59
27 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | #
2 | # Automatically tag a merge with main.
3 | #
4 |
5 | name: Release
6 |
7 | on:
8 | push:
9 | branches:
10 | - main
11 | paths-ignore:
12 | - "README.md"
13 |
14 | jobs:
15 | release:
16 | name: Tag
17 | runs-on: ubuntu-latest
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v3
21 | with:
22 | fetch-depth: "0" # make sure we get all commits!
23 |
24 | - name: Bump version and push tag
25 | id: bump
26 | uses: anothrNick/github-tag-action@1.52.0
27 | env:
28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29 | RELEASE_BRANCHES: release
30 | WITH_V: true
31 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Test Go
2 | on: [push]
3 | jobs:
4 | test:
5 | name: Test
6 | runs-on: ubuntu-latest
7 |
8 | steps:
9 | - name: Check out code
10 | uses: actions/checkout@v3
11 |
12 | - name: Set up Go
13 | uses: actions/setup-go@v4
14 | with:
15 | go-version-file: "go.mod"
16 |
17 | - name: Install Dependencies
18 | env:
19 | GOPROXY: https://proxy.golang.org,direct
20 | run: go mod download
21 |
22 | - name: Test
23 | run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
24 |
25 | - name: Upload coverage reports to Codecov
26 | uses: codecov/codecov-action@v4.0.1
27 | with:
28 | token: ${{ secrets.CODECOV_TOKEN }}
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 |
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 |
20 | # Go workspace file
21 | go.work
22 |
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
1 | run:
2 | timeout: "120s"
3 |
4 | output:
5 | formats:
6 | - format: "colored-line-number"
7 |
8 | linters:
9 | enable:
10 | - "gocyclo"
11 | - "unconvert"
12 | - "goimports"
13 | - "govet"
14 | #- "misspell" # doesn't handle multilanguage well
15 | - "nakedret"
16 | - "revive"
17 | - "goconst"
18 | - "unparam"
19 | - "gofmt"
20 | - "errname"
21 | - "zerologlint"
22 |
23 | linters-settings:
24 | staticcheck:
25 | # SAxxxx checks in https://staticcheck.io/docs/configuration/options/#checks
26 | # Default: ["*"]
27 | checks: ["all"]
28 |
29 | issues:
30 | exclude-use-default: false
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ctxi18n
2 |
3 | [](https://github.com/invopop/ctxi18n/actions/workflows/lint.yaml)
4 | [](https://github.com/invopop/ctxi18n/actions/workflows/test.yaml)
5 | [](https://goreportcard.com/report/github.com/invopop/ctxi18n)
6 | [](https://codecov.io/gh/invopop/ctxi18n)
7 | [](https://godoc.org/github.com/invopop/ctxi18n)
8 | 
9 |
10 | Go Context Internationalization - translating apps easily.
11 |
12 | ## Introduction
13 |
14 | `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.
15 |
16 | As the name suggests, `ctxi18n` focusses on making i18n data available inside an application's context instances, but is sufficiently flexible to used directly if needed.
17 |
18 | Key Features:
19 |
20 | - Loads locale files written in YAML or JSON with a similar structure those in Ruby i18n.
21 | - Makes it easy to add a locale object to the context.
22 | - Supports `fs.FS` to load data.
23 | - Short method names like `i18n.T()` or `i18n.N()`.
24 | - Support for simple interpolation using keys, e.g. `Some %{key} text`
25 | - Support for pluralization rules.
26 | - Default values when translations are missing.
27 |
28 | ## Usage
29 |
30 | Import the library with:
31 |
32 | ```go
33 | import "github.com/invopop/ctxi18n"
34 | ```
35 |
36 | First you'll need to load YAML or JSON translation definitions. Files may be named and structured however you like, but the contents must always follow the same pattern of language and properties, for example:
37 |
38 | ```yaml
39 | en:
40 | welcome:
41 | title: "Welcome to our application!"
42 | login: "Log in"
43 | signup: "Sign up"
44 | forgot-password: "Forgot Password?"
45 | es:
46 | welcome:
47 | title: "¡Bienvenido a nuestra aplicación!"
48 | login: "Ingresarse"
49 | signup: "Registrarse"
50 | forgot-password: "¿Olvidaste tu contraseña?
51 | ```
52 |
53 | The first level of properties of the object **must** always define the locale that the rest of sub-object's contents will provide translations for.
54 |
55 | Files will all be deep-merged on top of each other so you can safely extend dictionaries from multiple sources.
56 |
57 | To load the dictionary run something like the following where the `asset.Content` is a package containing [embedded files](https://pkg.go.dev/embed):
58 |
59 | ```go
60 | if err := ctxi18n.Load(assets.Content); err != nil {
61 | panic(err)
62 | }
63 | ```
64 |
65 | If you'd like to set a default base language to try to use for any missing translations, load the assets with a default:
66 |
67 | ```go
68 | if err := ctxi18n.LoadWithDefault(assets.Content, "en"); err != nil {
69 | panic(err)
70 | }
71 | ```
72 |
73 | You'll now have a global set of locales prepared in memory and ready to use. Assuming your application uses some kind of context such as from an HTTP or gRPC request, you'll want to add a single locale to it:
74 |
75 | ```go
76 | ctx = ctxi18n.WithLocale(ctx, "en")
77 | ```
78 |
79 | Locale selection is performed according to [RFC9110](https://www.rfc-editor.org/rfc/rfc9110.html) and the `Accept-Language` header, so you can pass in a code string and an attempt will be made to find the best match:
80 |
81 | ```go
82 | ctx = ctxi18n.WithLocale(ctx, "en-US,en;q=0.9,es;q=0.8")
83 | ```
84 |
85 | In this example, the first locale to matched will be `en-US`, followed by just `en`, then `es`:
86 |
87 | Getting translations is straightforward, you have two options:
88 |
89 | 1. call methods defined in the package with the context, or,
90 | 2. extract the locale from the context and use.
91 |
92 | 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:
93 |
94 | ```go
95 | import "github.com/invopop/ctxi18n/i18n"
96 | ```
97 |
98 | Then use it with the context:
99 |
100 | ```go
101 | fmt.Println(i18n.T(ctx, "welcome.title"))
102 | ```
103 |
104 | 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`.
105 |
106 | To use the `Locale` object directly, extract it from the context and call the methods:
107 |
108 | ```go
109 | l := ctxi18n.Locale(ctx)
110 | fmt.Println(l.T("welcome.title"))
111 | ```
112 |
113 | There is no preferred way on how to use this library, so please use whatever best first your application and coding style. Sometimes it makes sense to pass in the context in every call, other times the code can be shorter and more concise by extracting it.
114 |
115 | ### Defaults
116 |
117 | If a translation is missing from the locale a "missing" text will be produced, for example:
118 |
119 | ```go
120 | fmt.Println(l.T("welcome.no.text"))
121 | ```
122 |
123 | Will return a text that follows the `fmt.Sprintf` missing convention:
124 |
125 | ```
126 | !(MISSING welcome.no.text)
127 | ```
128 |
129 | This can be useful for translators to figure out which texts are missing, but sometimes a default value is more appropriate:
130 |
131 | ```go
132 | fmt.Println(i18n.T(ctx, "welcome.question", i18n.Default("Just ask!")))
133 | // output: "Just ask!"
134 | code := "EUR"
135 | fmt.Println(i18n.T(ctx, "currencies."+code, i18n.Default(code)))
136 | // output: "EUR"
137 | ```
138 |
139 | An alternative to using defaults is to check if the key exists using the `Has` method:
140 |
141 | ```go
142 | if !i18n.Has(ctx, "welcome.question") {
143 | fmt.Println("Just ask!")
144 | }
145 | ```
146 |
147 | ### Interpolation
148 |
149 | Go's default approach for interpolation using the `fmt.Sprintf` and related methods is good for simple use-cases. For example, given the following translation:
150 |
151 | ```yaml
152 | en:
153 | welcome:
154 | title: "Hi %s, welcome to our App!"
155 | ```
156 |
157 | You can get the translated text and interpolate with:
158 |
159 | ```go
160 | i18n.T(ctx, "welcome.title", "Sam")
161 | ```
162 |
163 | This however is an _anti-pattern_ when it comes to translating applications as translators may need to change the order of replaced words. To get around this, `ctxi18n` supports simple named interpolation as follows:
164 |
165 | ```yaml
166 | en:
167 | welcome:
168 | title: "Hi %{name}, welcome to our App!"
169 | ```
170 |
171 | ```go
172 | i18n.T(ctx, "welcome.title", i18n.M{"name":"Sam"})
173 | ```
174 |
175 | The `i18n.M` map is used to perform a simple find and replace on the matching translation. The `fmt.Sprint` method is used to convert values into strings, so you don't need to worry about simple serialization like for numbers.
176 |
177 | Interpolation can also be used alongside default values:
178 |
179 | ```go
180 | i18n.T(ctx, "welcome.title", i18n.Default("Hi %{name}"), i18n.M{"name":"Sam"})
181 | ```
182 |
183 | ## Pluralization
184 |
185 | When texts include references to numbers we need internationalization libraries like `ctxi18n` that help define multiple possible translations according to a number. Pluralized translations are defined like this:
186 |
187 | ```yaml
188 | en:
189 | inbox:
190 | emails:
191 | zero: "You have no emails."
192 | one: "You have %{count} email."
193 | other: "You have %{count} emails.
194 | ```
195 |
196 | 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.
197 |
198 | To use these translations, call the `i18n.N` method:
199 |
200 | ```go
201 | count := 2
202 | fmt.Println(i18n.N(ctx, "inbox.emails", count, i18n.M{"count": count}))
203 | ```
204 |
205 | The output from this will be: "You have 2 emails."
206 |
207 | In the current implementation of `ctxi18n` there are very few pluralization rules defined, please submit PRs if your language is not covered!
208 |
209 | ## Scopes
210 |
211 | 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:
212 |
213 | ```go
214 | ctx := i18n.WithScope(ctx, "welcome")
215 | i18n.T(ctx, ".title", i18n.M{"name":"Sam"})
216 | ```
217 |
218 | 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.
219 |
220 | ## Templ
221 |
222 | [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.
223 |
224 | 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.
225 |
226 | ```yaml
227 | en:
228 | welcome:
229 | hello: "Hello, %{name}"
230 | ```
231 |
232 | ```go
233 | package main
234 |
235 | import "github.com/invopop/ctxi18n/i18n"
236 |
237 | templ Hello(name string) {
238 |
239 | { i18n.T(ctx, "welcome.hello", i18n.M{"name": name}) }
240 |
241 | }
242 |
243 | templ Greeting(person Person) {
244 |
245 | @Hello(person.Name)
246 |
247 | }
248 | ```
249 |
250 | To save even more typing, it might be worth defining your own templ wrappers around those defined in the `i18n` package. Check out the [gobl.html `t` package](https://github.com/invopop/gobl.html/tree/main/components/t) for an example.
251 |
252 | # Examples
253 |
254 | The following is a list of Open Source projects using this library from which you can see working examples for your own solutions. Please send in a PR if you'd like to add your project!
255 |
256 | - [GOBL HTML](https://github.com/invopop/gobl.html) - generate HTML files like invoices from [GOBL](https://gobl.org) documents.
257 |
--------------------------------------------------------------------------------
/ctxi18n.go:
--------------------------------------------------------------------------------
1 | // Package ctxi18n is used to internationalize applications using the context
2 | // for the locale.
3 | package ctxi18n
4 |
5 | import (
6 | "context"
7 | "errors"
8 | "io/fs"
9 |
10 | "github.com/invopop/ctxi18n/i18n"
11 | )
12 |
13 | var (
14 | // DefaultLocale defines the default or fallback locale code to use
15 | // if no other match inside the packages list was found.
16 | DefaultLocale i18n.Code = "en"
17 | )
18 |
19 | var (
20 | locales *i18n.Locales
21 | )
22 |
23 | var (
24 | // ErrMissingLocale implies that the requested locale was not found
25 | // in the current index.
26 | ErrMissingLocale = errors.New("locale not defined")
27 | )
28 |
29 | func init() {
30 | locales = new(i18n.Locales)
31 | }
32 |
33 | // Load walks through all the files in provided File System and prepares
34 | // an internal global list of locales ready to use.
35 | func Load(fs fs.FS) error {
36 | return locales.Load(fs)
37 | }
38 |
39 | // LoadWithDefault performs the regular load operation, but will merge
40 | // the default locale with every other locale, ensuring that every text
41 | // has at least the value from the default locale.
42 | func LoadWithDefault(fs fs.FS, locale i18n.Code) error {
43 | return locales.LoadWithDefault(fs, locale)
44 | }
45 |
46 | // Get provides the Locale object for the matching code.
47 | func Get(code i18n.Code) *i18n.Locale {
48 | return locales.Get(code)
49 | }
50 |
51 | // Match attempts to find the best possible matching locale based on the
52 | // locale string provided. The locale string is parsed according to the
53 | // "Accept-Language" header format defined in RFC9110.
54 | func Match(locale string) *i18n.Locale {
55 | return locales.Match(locale)
56 | }
57 |
58 | // WithLocale tries to match the provided code with a locale and ensures
59 | // it is available inside the context.
60 | func WithLocale(ctx context.Context, locale string) (context.Context, error) {
61 | l := locales.Match(locale)
62 | if l == nil {
63 | l = locales.Get(DefaultLocale)
64 | if l == nil {
65 | return nil, ErrMissingLocale
66 | }
67 | }
68 | return l.WithContext(ctx), nil
69 | }
70 |
71 | // Locale provides the locale object currently stored in the context.
72 | func Locale(ctx context.Context) *i18n.Locale {
73 | return i18n.GetLocale(ctx)
74 | }
75 |
--------------------------------------------------------------------------------
/ctxi18n_test.go:
--------------------------------------------------------------------------------
1 | package ctxi18n_test
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/invopop/ctxi18n"
8 | "github.com/invopop/ctxi18n/i18n"
9 | "github.com/invopop/ctxi18n/internal/examples"
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestDefaults(t *testing.T) {
15 | assert.Equal(t, i18n.Code("en"), ctxi18n.DefaultLocale)
16 | }
17 |
18 | func TestLoad(t *testing.T) {
19 | err := ctxi18n.Load(examples.Content)
20 | assert.NoError(t, err)
21 |
22 | l := ctxi18n.Get("en")
23 | assert.NotNil(t, l)
24 | assert.Equal(t, "en", l.Code().String())
25 | }
26 |
27 | func TestLoadWithDefault(t *testing.T) {
28 | err := ctxi18n.LoadWithDefault(examples.Content, "en")
29 | assert.NoError(t, err)
30 |
31 | l := ctxi18n.Get("en")
32 | assert.NotNil(t, l)
33 | assert.Equal(t, "en", l.Code().String())
34 | assert.Equal(t, "Special Label", l.T("special_label"))
35 | l = ctxi18n.Get("es")
36 | assert.NotNil(t, l)
37 | assert.Equal(t, "es", l.Code().String())
38 | assert.Equal(t, "Special Label", l.T("special_label"))
39 | }
40 |
41 | func TestGet(t *testing.T) {
42 | err := ctxi18n.Load(examples.Content)
43 | assert.NoError(t, err)
44 |
45 | l := ctxi18n.Get("en")
46 | assert.NotNil(t, l)
47 | assert.Equal(t, "en", l.Code().String())
48 |
49 | l = ctxi18n.Get("bad")
50 | assert.Nil(t, l)
51 | }
52 |
53 | func TestMatch(t *testing.T) {
54 | err := ctxi18n.Load(examples.Content)
55 | require.NoError(t, err)
56 |
57 | l := ctxi18n.Match("en-US,en;q=0.9,es;q=0.8")
58 | assert.NotNil(t, l)
59 | assert.Equal(t, "en", l.Code().String())
60 | }
61 |
62 | func TestWithLocale(t *testing.T) {
63 | err := ctxi18n.Load(examples.Content)
64 | require.NoError(t, err)
65 |
66 | ctx := context.Background()
67 | ctx, err = ctxi18n.WithLocale(ctx, "en-US,en;q=0.9,es;q=0.8")
68 | require.NoError(t, err)
69 |
70 | l := ctxi18n.Locale(ctx)
71 | assert.NotNil(t, l)
72 | assert.Equal(t, "en", l.Code().String())
73 |
74 | // Use the default locale if not set
75 | ctx, err = ctxi18n.WithLocale(ctx, "inv")
76 | assert.NoError(t, err)
77 | l = ctxi18n.Locale(ctx)
78 | assert.NotNil(t, l)
79 | assert.Equal(t, "en", l.Code().String())
80 |
81 | ctxi18n.DefaultLocale = "bad"
82 | _, err = ctxi18n.WithLocale(ctx, "inv")
83 | assert.ErrorIs(t, err, ctxi18n.ErrMissingLocale)
84 | ctxi18n.DefaultLocale = "es"
85 |
86 | }
87 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/invopop/ctxi18n
2 |
3 | go 1.21
4 |
5 | toolchain go1.22.1
6 |
7 | require (
8 | github.com/a-h/templ v0.2.598
9 | github.com/invopop/yaml v0.2.0
10 | github.com/stretchr/testify v1.9.0
11 | )
12 |
13 | require (
14 | github.com/davecgh/go-spew v1.1.1 // indirect
15 | github.com/pmezard/go-difflib v1.0.0 // indirect
16 | gopkg.in/yaml.v3 v3.0.1 // indirect
17 | )
18 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/a-h/templ v0.2.598 h1:6jMIHv6wQZvdPxTuv87erW4RqN/FPU0wk7ZHN5wVuuo=
2 | github.com/a-h/templ v0.2.598/go.mod h1:SA7mtYwVEajbIXFRh3vKdYm/4FYyLQAtPH1+KxzGPA8=
3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
7 | github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY=
8 | github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
11 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
12 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
15 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
16 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
17 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
18 |
--------------------------------------------------------------------------------
/i18n/code.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import "strings"
4 |
5 | // Code is used to represent a language code which follows the
6 | // ISO 639-1 standard, with sub-tags aggregated with hyphens,
7 | // as defined in [RFC5646](https://datatracker.ietf.org/doc/html/rfc5646).
8 | // Examples include:
9 | //
10 | // fr, en-US, es-419, az-Arab, x-pig-latin, man-Nkoo-GN
11 | type Code string
12 |
13 | // String returns the string variant of the code.
14 | func (c Code) String() string {
15 | return string(c)
16 | }
17 |
18 | // Base returns the base language code, without any subtags.
19 | func (c Code) Base() Code {
20 | out := strings.SplitN(c.String(), "-", 2)
21 | return Code(out[0])
22 | }
23 |
24 | // ParseAcceptLanguage provides an ordered set of codes extracted
25 | // from an HTTP "Accept-Language" header as defined in RFC9110.
26 | // Current implementation will ignore quality values and instead
27 | // just assume the order of the provided codes is valid.
28 | func ParseAcceptLanguage(txt string) []Code {
29 | list := make([]Code, 0)
30 | for _, s := range strings.Split(txt, ",") {
31 | s = strings.TrimSpace(s)
32 |
33 | // Remove any quality values.
34 | if i := strings.Index(s, ";"); i > 0 {
35 | s = s[:i]
36 | }
37 |
38 | list = append(list, Code(s))
39 | }
40 | return list
41 | }
42 |
--------------------------------------------------------------------------------
/i18n/code_test.go:
--------------------------------------------------------------------------------
1 | package i18n_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/invopop/ctxi18n/i18n"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestCode(t *testing.T) {
11 | c := i18n.Code("en-US")
12 | assert.Equal(t, "en-US", c.String())
13 | assert.Equal(t, i18n.Code("en"), c.Base())
14 |
15 | c = i18n.Code("en")
16 | assert.Equal(t, i18n.Code("en"), c.Base())
17 |
18 | c = i18n.Code("x")
19 | assert.Equal(t, i18n.Code("x"), c.Base())
20 |
21 | c = i18n.Code("")
22 | assert.Equal(t, i18n.Code(""), c.Base())
23 | }
24 |
25 | func TestCodeBase(t *testing.T) {
26 | c := i18n.Code("en-US")
27 | assert.Equal(t, "en", c.Base().String())
28 |
29 | c = i18n.Code("en")
30 | assert.Equal(t, "en", c.Base().String())
31 |
32 | c = i18n.Code("x")
33 | assert.Equal(t, "x", c.Base().String())
34 |
35 | c = i18n.Code("")
36 | assert.Equal(t, "", c.Base().String())
37 | }
38 |
39 | func TestParseAcceptLanguage(t *testing.T) {
40 | list := i18n.ParseAcceptLanguage("en")
41 | assert.Equal(t, []i18n.Code{"en"}, list)
42 |
43 | list = i18n.ParseAcceptLanguage("en-US, en;q=0.5")
44 | assert.Equal(t, []i18n.Code{"en-US", "en"}, list)
45 |
46 | list = i18n.ParseAcceptLanguage("en-US, en;q=0.5, es-419;q=0.8")
47 | assert.Equal(t, []i18n.Code{"en-US", "en", "es-419"}, list)
48 | }
49 |
--------------------------------------------------------------------------------
/i18n/dict.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 | )
7 |
8 | // Dict holds the internationalization entries for a specific locale.
9 | type Dict struct {
10 | value string
11 | entries map[string]*Dict
12 | }
13 |
14 | // NewDict instantiates a new dict object.
15 | func NewDict() *Dict {
16 | return &Dict{
17 | entries: make(map[string]*Dict),
18 | }
19 | }
20 |
21 | // Add adds a new key value pair to the dictionary.
22 | func (d *Dict) Add(key string, value any) {
23 | switch v := value.(type) {
24 | case string:
25 | d.entries[key] = &Dict{value: v}
26 | case map[string]any:
27 | nd := NewDict()
28 | for k, row := range v {
29 | nd.Add(k, row)
30 | }
31 | d.entries[key] = nd
32 | case *Dict:
33 | d.entries[key] = v
34 | default:
35 | // ignore
36 | }
37 | }
38 |
39 | // Value returns the dictionary value or an empty string
40 | // if the dictionary is nil.
41 | func (d *Dict) Value() string {
42 | if d == nil {
43 | return ""
44 | }
45 | return d.value
46 | }
47 |
48 | // Get recursively retrieves the dictionary at the provided key location.
49 | func (d *Dict) Get(key string) *Dict {
50 | if d == nil {
51 | return nil
52 | }
53 | if key == "" {
54 | return nil
55 | }
56 | n := strings.SplitN(key, ".", 2)
57 | entry, ok := d.entries[n[0]]
58 | if !ok {
59 | return nil
60 | }
61 | if len(n) == 1 {
62 | return entry
63 | }
64 | return entry.Get(n[1])
65 | }
66 |
67 | // Has is a convenience method to check if a key exists in the dictionary
68 | // recursively, and is the equivalent of calling `Get` and checking if
69 | // the result is not nil.
70 | func (d *Dict) Has(key string) bool {
71 | return d.Get(key) != nil
72 | }
73 |
74 | // Merge combines the entries of the second dictionary into this one. If a
75 | // key is duplicated in the second diction, the original value takes priority.
76 | func (d *Dict) Merge(d2 *Dict) {
77 | if d2 == nil {
78 | return
79 | }
80 | if d.entries == nil {
81 | d.entries = make(map[string]*Dict)
82 | }
83 | for k, v := range d2.entries {
84 | if d.entries[k] == nil {
85 | d.entries[k] = v
86 | continue
87 | }
88 | d.entries[k].Merge(v)
89 | }
90 | }
91 |
92 | // UnmarshalJSON attempts to load the dictionary data from a JSON byte slice.
93 | func (d *Dict) UnmarshalJSON(data []byte) error {
94 | if len(data) == 0 {
95 | return nil
96 | }
97 | if data[0] == '"' {
98 | d.value = string(data[1 : len(data)-1])
99 | return nil
100 | }
101 | d.entries = make(map[string]*Dict)
102 | return json.Unmarshal(data, &d.entries)
103 | }
104 |
--------------------------------------------------------------------------------
/i18n/dict_test.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestDictUnmarshalJSON(t *testing.T) {
12 | ex := `{
13 | "foo": "bar",
14 | "baz": {
15 | "qux": "quux",
16 | "plural": {
17 | "zero": "no mice",
18 | "one": "%s mouse",
19 | "other": "%s mice"
20 | }
21 | }
22 | }`
23 | dict := new(Dict)
24 | err := json.Unmarshal([]byte(ex), dict)
25 | require.NoError(t, err)
26 | assert.Equal(t, "bar", dict.Get("foo").Value())
27 | assert.Equal(t, "quux", dict.Get("baz.qux").Value())
28 | assert.Empty(t, dict.Get("baz.plural").Value())
29 | assert.Empty(t, dict.Get("random").Value())
30 |
31 | t.Run("empty", func(t *testing.T) {
32 | d := new(Dict)
33 | err := d.UnmarshalJSON([]byte{})
34 | require.NoError(t, err)
35 | })
36 | }
37 |
38 | func TestDictAdd(t *testing.T) {
39 | d := NewDict()
40 | assert.Nil(t, d.Get(""))
41 | d.Add("foo", "bar")
42 | assert.Equal(t, "bar", d.Get("foo").Value())
43 |
44 | d.Add("plural", map[string]any{
45 | "zero": "no mice",
46 | "one": "%s mouse",
47 | "other": "%s mice",
48 | })
49 | assert.Equal(t, "no mice", d.Get("plural.zero").Value())
50 | assert.Equal(t, "%s mice", d.Get("plural.other").Value())
51 |
52 | d.Add("bad", 10) // ignore
53 | assert.Nil(t, d.Get("bad"))
54 |
55 | d.Add("self", d)
56 | assert.Equal(t, "bar", d.Get("self.foo").Value())
57 | }
58 |
59 | func TestDictHas(t *testing.T) {
60 | t.Run("simple case", func(t *testing.T) {
61 | d := NewDict()
62 | assert.False(t, d.Has("foo"))
63 | d.Add("foo", "bar")
64 | assert.True(t, d.Has("foo"))
65 | assert.False(t, d.Has("baz"))
66 | })
67 | t.Run("nested", func(t *testing.T) {
68 | ex := `{
69 | "foo": "bar",
70 | "baz": {
71 | "qux": "quux"
72 | }
73 | }`
74 | d := new(Dict)
75 | require.NoError(t, json.Unmarshal([]byte(ex), d))
76 | assert.True(t, d.Has("foo"))
77 | assert.True(t, d.Has("baz.qux"))
78 | assert.False(t, d.Has("baz.quux"))
79 | })
80 | }
81 |
82 | func TestDictMerge(t *testing.T) {
83 | ex := `{
84 | "foo": "bar",
85 | "baz": {
86 | "qux": "quux",
87 | "plural": {
88 | "zero": "no mice",
89 | "one": "%s mouse",
90 | "other": "%s mice"
91 | }
92 | }
93 | }`
94 | d1 := new(Dict)
95 | require.NoError(t, json.Unmarshal([]byte(ex), d1))
96 |
97 | ex2 := `{
98 | "foo": "baz",
99 | "extra": "value"
100 | }`
101 | d2 := new(Dict)
102 | require.NoError(t, json.Unmarshal([]byte(ex2), d2))
103 |
104 | d1.Merge(nil) // does nothing
105 |
106 | d3 := new(Dict)
107 | d3.Merge(d2)
108 | assert.Equal(t, "value", d3.Get("extra").Value())
109 |
110 | d1.Merge(d2)
111 | assert.Equal(t, "bar", d1.Get("foo").Value(), "should not overwrite")
112 | assert.Equal(t, "value", d1.Get("extra").Value())
113 | }
114 |
--------------------------------------------------------------------------------
/i18n/i18n.go:
--------------------------------------------------------------------------------
1 | // Package i18n is responsible for keeping the key internationalization in one
2 | // place.
3 | package i18n
4 |
5 | import (
6 | "context"
7 | "fmt"
8 | "strings"
9 | )
10 |
11 | const (
12 | missingLocaleOut = "!(MISSING LOCALE)"
13 | )
14 |
15 | type scopeType string
16 |
17 | const (
18 | scopeKey scopeType = "scope"
19 | )
20 |
21 | // M stands for map and is a simple helper to make it easier to work with
22 | // internationalization maps.
23 | type M map[string]any
24 |
25 | // T is responsible for translating a key into a string by extracting
26 | // the local from the context.
27 | func T(ctx context.Context, key string, args ...any) string {
28 | l := GetLocale(ctx)
29 | if l == nil {
30 | return missingLocaleOut
31 | }
32 | key = ExpandKey(ctx, key)
33 | return l.T(key, args...)
34 | }
35 |
36 | // N returns the pluralized translation of the provided key using n
37 | // as the count.
38 | func N(ctx context.Context, key string, n int, args ...any) string {
39 | l := GetLocale(ctx)
40 | if l == nil {
41 | return missingLocaleOut
42 | }
43 | key = ExpandKey(ctx, key)
44 | return l.N(key, n, args...)
45 | }
46 |
47 | // Has performs a check to see if the key exists in the locale.
48 | func Has(ctx context.Context, key string) bool {
49 | l := GetLocale(ctx)
50 | if l == nil {
51 | return false
52 | }
53 | key = ExpandKey(ctx, key)
54 | return l.Has(key)
55 | }
56 |
57 | // WithScope is used to add a new scope to the context. To use this,
58 | // use a `.` at the beginning of keys.
59 | func WithScope(ctx context.Context, key string) context.Context {
60 | key = ExpandKey(ctx, key)
61 | return context.WithValue(ctx, scopeKey, key)
62 | }
63 |
64 | // ExpandKey extracts the current scope from the context and appends it
65 | // to the start of the provided key.
66 | func ExpandKey(ctx context.Context, key string) string {
67 | if !strings.HasPrefix(key, ".") {
68 | return key
69 | }
70 | scope, ok := ctx.Value(scopeKey).(string)
71 | if !ok {
72 | return key
73 | }
74 | return fmt.Sprintf("%s%s", scope, key)
75 | }
76 |
77 | // Replace is used to interpolate the matched keys in the provided
78 | // string with their values in the map.
79 | //
80 | // Interpolation is performed using the `%{key}` pattern.
81 | func (m M) Replace(in string) string {
82 | for k, v := range m {
83 | in = strings.Replace(in, fmt.Sprintf("%%{%s}", k), fmt.Sprint(v), -1)
84 | }
85 | return in
86 | }
87 |
--------------------------------------------------------------------------------
/i18n/i18n_test.go:
--------------------------------------------------------------------------------
1 | package i18n_test
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "testing"
7 |
8 | "github.com/invopop/ctxi18n/i18n"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestMReplace(t *testing.T) {
14 | m := i18n.M{
15 | "string": "value",
16 | "num": 42,
17 | }
18 | out := m.Replace("This is a %{string} and a %{num}.")
19 | assert.Equal(t, "This is a value and a 42.", out)
20 | }
21 |
22 | func TestT(t *testing.T) {
23 | d := i18n.NewDict()
24 | d.Add("key", "value")
25 | l := i18n.NewLocale("en", d)
26 | ctx := l.WithContext(context.Background())
27 | assert.Equal(t, "value", i18n.T(ctx, "key"))
28 |
29 | ctx = context.Background()
30 | assert.Equal(t, "!(MISSING LOCALE)", i18n.T(ctx, "key"))
31 | }
32 |
33 | func TestN(t *testing.T) {
34 | ctx := context.Background()
35 | assert.Equal(t, "!(MISSING LOCALE)", i18n.N(ctx, "key", 1))
36 |
37 | d := i18n.NewDict()
38 | d.Add("key", map[string]any{
39 | "zero": "no mice",
40 | "one": "%{count} mouse",
41 | "other": "%{count} mice",
42 | })
43 | l := i18n.NewLocale("en", d)
44 | ctx = l.WithContext(context.Background())
45 |
46 | assert.Equal(t, "no mice", i18n.N(ctx, "key", 0, i18n.M{"count": 0}))
47 | assert.Equal(t, "1 mouse", i18n.N(ctx, "key", 1, i18n.M{"count": 1}))
48 | assert.Equal(t, "2 mice", i18n.N(ctx, "key", 2, i18n.M{"count": 2}))
49 | }
50 |
51 | func TestHas(t *testing.T) {
52 | d := i18n.NewDict()
53 | d.Add("key", "value")
54 | l := i18n.NewLocale("en", d)
55 |
56 | ctx := l.WithContext(context.Background())
57 | assert.True(t, i18n.Has(ctx, "key"))
58 |
59 | ctx = context.Background()
60 | assert.False(t, i18n.Has(ctx, "key"))
61 | }
62 |
63 | func TestScopes(t *testing.T) {
64 | in := SampleLocaleData()
65 | l := i18n.NewLocale("en", nil)
66 | require.NoError(t, json.Unmarshal(in, l))
67 |
68 | ctx := l.WithContext(context.Background())
69 | ctxScoped := i18n.WithScope(ctx, "baz")
70 |
71 | assert.Equal(t, "quux", i18n.T(ctxScoped, ".qux"))
72 | assert.True(t, i18n.Has(ctxScoped, ".qux"))
73 | assert.Equal(t, "!(MISSING: baz.bad)", i18n.T(ctxScoped, ".bad"))
74 | assert.False(t, i18n.Has(ctx, ".bad"))
75 | assert.Equal(t, "quux", i18n.T(ctx, "baz.qux"))
76 | assert.Equal(t, "!(MISSING: .qux)", i18n.T(ctx, ".qux"))
77 | assert.False(t, i18n.Has(ctx, ".qux"))
78 |
79 | assert.Equal(t, "no mice", i18n.N(ctxScoped, ".mice", 0, i18n.M{"count": 0}))
80 |
81 | ctxScoped = i18n.WithScope(ctxScoped, ".mice")
82 | assert.Equal(t, "no mice", i18n.T(ctxScoped, ".zero"))
83 | }
84 |
--------------------------------------------------------------------------------
/i18n/locale.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | )
8 |
9 | // Locale holds the internationalization entries for a specific locale.
10 | type Locale struct {
11 | code Code
12 | dict *Dict
13 | rule PluralRule
14 | }
15 |
16 | const (
17 | missingDictOut = "!(MISSING: %s)"
18 | localeKey Code = "locale"
19 | )
20 |
21 | // DefaultText when detected as an argument to a translation
22 | // function will be used if no language match is found.
23 | type DefaultText string
24 |
25 | // Default when used as an argument to a translation function
26 | // ensure the provided txt is used as a default value if no
27 | // language match is found.
28 | func Default(txt string) DefaultText {
29 | return DefaultText(txt)
30 | }
31 |
32 | // NewLocale creates a new locale with the provided key and dictionary.
33 | func NewLocale(code Code, dict *Dict) *Locale {
34 | l := &Locale{
35 | code: code,
36 | dict: dict,
37 | }
38 | l.rule = mapPluralRule(code)
39 | return l
40 |
41 | }
42 |
43 | // Code returns the language code of the locale.
44 | func (l *Locale) Code() Code {
45 | return l.code
46 | }
47 |
48 | // T provides the value from the dictionary stored by the locale.
49 | func (l *Locale) T(key string, args ...any) string {
50 | return interpolate(key, l.dict.Get(key), args...)
51 | }
52 |
53 | // N uses the locale pluralization rules to determine which
54 | // string value to provide based on the provided number.
55 | func (l *Locale) N(key string, n int, args ...any) string {
56 | d := l.dict.Get(key)
57 | return interpolate(key, l.rule(d, n), args...)
58 | }
59 |
60 | // Has performs a check to see if the key exists in the locale.
61 | // This is useful for checking if a key exists before attempting
62 | // to use it when the Default function cannot be used.
63 | func (l *Locale) Has(key string) bool {
64 | return l.dict.Has(key)
65 | }
66 |
67 | // PluralRule provides the pluralization rule for the locale.
68 | func (l *Locale) PluralRule() PluralRule {
69 | return l.rule
70 | }
71 |
72 | // UnmarshalJSON attempts to load the locale from a JSON byte slice.
73 | func (l *Locale) UnmarshalJSON(data []byte) error {
74 | if len(data) == 0 {
75 | return nil
76 | }
77 | l.dict = new(Dict)
78 | if err := json.Unmarshal(data, l.dict); err != nil {
79 | return err
80 | }
81 | return nil
82 | }
83 |
84 | // WithContext inserts the locale into the context so that it can be
85 | // loaded later with `GetLocale`.
86 | func (l *Locale) WithContext(ctx context.Context) context.Context {
87 | return context.WithValue(ctx, localeKey, l)
88 | }
89 |
90 | // GetLocale retrieves the locale from the context.
91 | func GetLocale(ctx context.Context) *Locale {
92 | if l, ok := ctx.Value(localeKey).(*Locale); ok {
93 | return l
94 | }
95 | return nil
96 | }
97 |
98 | func interpolate(key string, d *Dict, args ...any) string {
99 | var s string
100 | s, args = extractDefault(args)
101 | if d != nil {
102 | s = d.value
103 | }
104 | if s == "" {
105 | return missing(key)
106 | }
107 | if len(args) > 0 {
108 | switch arg := args[0].(type) {
109 | case M:
110 | return arg.Replace(s)
111 | default:
112 | return fmt.Sprintf(s, args...)
113 | }
114 | }
115 | return s
116 | }
117 |
118 | func extractDefault(args []any) (string, []any) {
119 | for i, arg := range args {
120 | if dt, ok := arg.(DefaultText); ok {
121 | return string(dt), append(args[:i], args[i+1:]...)
122 | }
123 | }
124 | return "", args
125 | }
126 |
127 | func missing(key string) string {
128 | return fmt.Sprintf(missingDictOut, key)
129 | }
130 |
--------------------------------------------------------------------------------
/i18n/locale_test.go:
--------------------------------------------------------------------------------
1 | package i18n_test
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "testing"
7 |
8 | "github.com/invopop/ctxi18n/i18n"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestLocaleGet(t *testing.T) {
14 | in := SampleLocaleData()
15 | l := i18n.NewLocale("en", nil)
16 |
17 | assert.NotNil(t, l.PluralRule())
18 | err := json.Unmarshal(in, l)
19 | require.NoError(t, err)
20 |
21 | assert.Equal(t, "bar", l.T("foo"))
22 | assert.Equal(t, "quux", l.T("baz.qux"))
23 | assert.Equal(t, "!(MISSING: baz.random)", l.T("baz.random"))
24 | assert.Equal(t, "no mice", l.N("baz.mice", 0, i18n.M{"count": 0}))
25 | assert.Equal(t, "1 mouse", l.N("baz.mice", 1, i18n.M{"count": 1}))
26 | assert.Equal(t, "2 mice", l.N("baz.mice", 2, i18n.M{"count": 2}))
27 | assert.Equal(t, "!(MISSING: random)", l.N("random", 2))
28 |
29 | assert.Equal(t, "xyz", l.T("random", i18n.Default("xyz")))
30 | assert.Equal(t, "xyz test", l.T("random", i18n.Default("xyz %{foo}"), i18n.M{"foo": "test"}))
31 | assert.Equal(t, "xyz test", l.T("random", i18n.M{"foo": "test"}, i18n.Default("xyz %{foo}")))
32 | assert.Equal(t, "2 mouses", l.N("baz.random", 2, i18n.Default("%{count} mouses"), i18n.M{"count": 2}))
33 | }
34 |
35 | func TestLocaleHas(t *testing.T) {
36 | in := SampleLocaleData()
37 | l := i18n.NewLocale("en", nil)
38 | require.NoError(t, json.Unmarshal(in, l))
39 |
40 | assert.True(t, l.Has("foo"))
41 | assert.True(t, l.Has("baz.qux"))
42 | assert.False(t, l.Has("baz.random"))
43 | }
44 |
45 | func TestLocaleInterpolate(t *testing.T) {
46 | l := i18n.NewLocale("en", nil)
47 | require.NoError(t, json.Unmarshal(SampleLocaleData(), l))
48 |
49 | out := l.N("baz.ducks", 1, 1)
50 | assert.Equal(t, "1 duck", out)
51 | }
52 |
53 | func TestLocalWithContext(t *testing.T) {
54 | l := i18n.NewLocale("en", nil)
55 | require.NoError(t, json.Unmarshal(SampleLocaleData(), l))
56 |
57 | l2 := i18n.GetLocale(context.Background())
58 | assert.Nil(t, l2)
59 |
60 | // Prepare Context
61 | ctx := l.WithContext(context.Background())
62 | l2 = i18n.GetLocale(ctx)
63 | require.NotNil(t, l2)
64 | }
65 |
66 | func TestLocalUnmarshalJSON(t *testing.T) {
67 | l := i18n.NewLocale("en", nil)
68 | require.NoError(t, l.UnmarshalJSON(SampleLocaleData()))
69 |
70 | assert.Equal(t, "bar", l.T("foo"))
71 |
72 | t.Run("empty", func(t *testing.T) {
73 | ls := new(i18n.Locale)
74 | err := ls.UnmarshalJSON([]byte{})
75 | require.NoError(t, err)
76 | })
77 | t.Run("invalid", func(t *testing.T) {
78 | ls := new(i18n.Locale)
79 | err := ls.UnmarshalJSON([]byte("'bad'"))
80 | require.ErrorContains(t, err, "invalid character")
81 | })
82 | }
83 |
84 | func SampleLocaleData() []byte {
85 | return []byte(`{
86 | "foo": "bar",
87 | "baz": {
88 | "qux": "quux",
89 | "mice": {
90 | "zero": "no mice",
91 | "one": "%{count} mouse",
92 | "other": "%{count} mice"
93 | },
94 | "ducks": {
95 | "one": "%d duck",
96 | "other": "%d ducks"
97 | }
98 | }
99 | }`)
100 | }
101 |
--------------------------------------------------------------------------------
/i18n/locales.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io/fs"
7 | "path/filepath"
8 |
9 | "github.com/invopop/yaml"
10 | )
11 |
12 | // Locales is a map of language keys to their respective locale.
13 | type Locales struct {
14 | list []*Locale
15 | }
16 |
17 | // Load walks through all the files in the provided File System
18 | // and merges every one with the current list of locales.
19 | func (ls *Locales) Load(src fs.FS) error {
20 | return fs.WalkDir(src, ".", func(path string, _ fs.DirEntry, err error) error {
21 | if err != nil {
22 | return fmt.Errorf("walking directory: %w", err)
23 | }
24 |
25 | switch filepath.Ext(path) {
26 | case ".yaml", ".yml", ".json":
27 | // good
28 | default:
29 | return nil
30 | }
31 |
32 | data, err := fs.ReadFile(src, path)
33 | if err != nil {
34 | return fmt.Errorf("reading file '%s': %w", path, err)
35 | }
36 |
37 | if err := yaml.Unmarshal(data, ls); err != nil {
38 | return fmt.Errorf("unmarshalling file '%s': %w", path, err)
39 | }
40 |
41 | return nil
42 | })
43 | }
44 |
45 | // LoadWithDefault performs the regular load operation, but follows up with
46 | // a second operation that will ensure that default dictionary is merged with
47 | // every other locale, thus ensuring that every text will have a fallback.
48 | func (ls *Locales) LoadWithDefault(src fs.FS, locale Code) error {
49 | if err := ls.Load(src); err != nil {
50 | return err
51 | }
52 |
53 | l := ls.Get(locale)
54 | if l == nil {
55 | return fmt.Errorf("undefined default locale: %s", locale)
56 | }
57 | for _, loc := range ls.list {
58 | if loc == l {
59 | continue
60 | }
61 | loc.dict.Merge(l.dict)
62 | }
63 |
64 | return nil
65 | }
66 |
67 | // Get provides the define Locale object for the matching key.
68 | func (ls *Locales) Get(code Code) *Locale {
69 | for _, loc := range ls.list {
70 | if loc.Code() == code {
71 | return loc
72 | }
73 | }
74 | return nil
75 | }
76 |
77 | // Match attempts to find the best possible matching locale based on the
78 | // locale string provided. The locale string is parsed according to the
79 | // "Accept-Language" header format defined in RFC9110.
80 | func (ls *Locales) Match(locale string) *Locale {
81 | codes := ParseAcceptLanguage(locale)
82 | for _, code := range codes {
83 | for _, loc := range ls.list {
84 | if loc.Code() == code {
85 | return loc
86 | }
87 | }
88 | }
89 | return nil
90 | }
91 |
92 | // Codes provides a list of locale codes defined in the
93 | // list.
94 | func (ls *Locales) Codes() []Code {
95 | codes := make([]Code, len(ls.list))
96 | for i, l := range ls.list {
97 | codes[i] = l.Code()
98 | }
99 | return codes
100 | }
101 |
102 | // UnmarshalJSON attempts to load the locales from a JSON byte slice
103 | // and merge them into any existing locales.
104 | func (ls *Locales) UnmarshalJSON(data []byte) error {
105 | if len(data) == 0 {
106 | return nil
107 | }
108 | aux := make(map[Code]*Dict)
109 | if err := json.Unmarshal(data, &aux); err != nil {
110 | return err
111 | }
112 | for c, v := range aux {
113 | if l := ls.Get(c); l != nil {
114 | l.dict.Merge(v)
115 | } else {
116 | ls.list = append(ls.list, NewLocale(c, v))
117 | }
118 | }
119 | return nil
120 | }
121 |
--------------------------------------------------------------------------------
/i18n/locales_test.go:
--------------------------------------------------------------------------------
1 | package i18n_test
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | "github.com/invopop/ctxi18n/i18n"
8 | "github.com/invopop/ctxi18n/internal/examples"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestLocalesLoad(t *testing.T) {
14 | ls := new(i18n.Locales)
15 |
16 | err := ls.Load(examples.Content)
17 | require.NoError(t, err)
18 |
19 | en := ls.Get("en")
20 | require.NotNil(t, en)
21 | assert.Equal(t, "Log In", en.T("login.button"))
22 | assert.Equal(t, "Extensions", en.T("ext.test"))
23 |
24 | es := ls.Get("es")
25 | require.NotNil(t, es)
26 | assert.Equal(t, "Iniciar Sesión", es.T("login.button"))
27 |
28 | l := ls.Match("en-US,en;q=0.9,es;q=0.8")
29 | require.NotNil(t, l)
30 | assert.Equal(t, "en", l.Code().String())
31 | assert.Equal(t, "Log In", l.T("login.button"))
32 | assert.Equal(t, "Special Label", l.T("special_label"))
33 |
34 | l = ls.Match("es-ES,es;q=0.9,en;q=0.8")
35 | require.NotNil(t, l)
36 | assert.Equal(t, "es", l.Code().String())
37 | assert.Equal(t, "Iniciar Sesión", l.T("login.button"))
38 | assert.Equal(t, "!(MISSING: special_label)", l.T("special_label"))
39 |
40 | assert.Nil(t, ls.Match("inv"))
41 | }
42 |
43 | func TestLoadWithDefault(t *testing.T) {
44 | ls := new(i18n.Locales)
45 | err := ls.LoadWithDefault(examples.Content, "en")
46 | require.NoError(t, err)
47 |
48 | l := ls.Match("en")
49 | require.NotNil(t, l)
50 | assert.Equal(t, "en", l.Code().String())
51 | assert.Equal(t, "Log In", l.T("login.button"))
52 | assert.Equal(t, "Special Label", l.T("special_label"))
53 |
54 | l = ls.Match("es")
55 | require.NotNil(t, l)
56 | assert.Equal(t, "es", l.Code().String())
57 | assert.Equal(t, "Iniciar Sesión", l.T("login.button"))
58 | assert.Equal(t, "Special Label", l.T("special_label"))
59 |
60 | ls = new(i18n.Locales)
61 | err = ls.LoadWithDefault(examples.Content, "bad")
62 | assert.ErrorContains(t, err, "undefined default locale: bad")
63 |
64 | }
65 |
66 | func TestLocalesUnmarshalJSON(t *testing.T) {
67 | in := SampleLocales()
68 | ls := new(i18n.Locales)
69 | err := json.Unmarshal(in, ls)
70 | require.NoError(t, err)
71 | l := ls.Get("en")
72 | assert.Equal(t, "en", l.Code().String())
73 | assert.Equal(t, "quux", l.T("baz.qux"))
74 |
75 | // Now try merging with another set of entries
76 | sub := []byte(`{
77 | "en": {
78 | "a": "b",
79 | "baz": {
80 | "zux": "zuux"
81 | }
82 | }
83 | }`)
84 | err = json.Unmarshal(sub, ls)
85 | require.NoError(t, err)
86 | assert.Equal(t, "en", l.Code().String())
87 | assert.Equal(t, "quux", l.T("baz.qux"))
88 | assert.Equal(t, "b", l.T("a"))
89 | assert.Equal(t, "zuux", l.T("baz.zux"))
90 |
91 | t.Run("empty", func(t *testing.T) {
92 | ls := new(i18n.Locales)
93 | err := ls.UnmarshalJSON([]byte{})
94 | require.NoError(t, err)
95 | })
96 | t.Run("invalid", func(t *testing.T) {
97 | ls := new(i18n.Locales)
98 | err := ls.UnmarshalJSON([]byte("'bad'"))
99 | require.ErrorContains(t, err, "invalid character")
100 | })
101 | }
102 |
103 | func TestLocalesCodes(t *testing.T) {
104 | in := SampleLocales()
105 | ls := new(i18n.Locales)
106 | require.NoError(t, json.Unmarshal(in, ls))
107 | codes := ls.Codes()
108 | assert.Len(t, codes, 2)
109 | assert.Contains(t, codes, i18n.Code("en"))
110 | assert.Contains(t, codes, i18n.Code("es"))
111 | }
112 |
113 | func SampleLocales() []byte {
114 | return []byte(`{
115 | "en": {
116 | "foo": "bar",
117 | "baz": {
118 | "qux": "quux",
119 | "plural": {
120 | "zero": "no mice",
121 | "one": "%{count} mouse",
122 | "other": "%{count} mice"
123 | }
124 | }
125 | },
126 | "es": {
127 | "foo": "bara",
128 | "baz": {
129 | "qux": "quuxa",
130 | "plural": {
131 | "zero": "no ratones",
132 | "one": "%{count} ratón",
133 | "other": "%{count} ratones"
134 | }
135 | }
136 | }
137 | }`)
138 | }
139 |
--------------------------------------------------------------------------------
/i18n/plural_rules.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | // Standard pluralization rule keys.
4 | const (
5 | DefaultRuleKey = "default"
6 | )
7 |
8 | // PluralRule defines a simple method that expects a dictionary and number and
9 | // will find a matching dictionary entry.
10 | type PluralRule func(d *Dict, num int) *Dict
11 |
12 | const (
13 | zeroKey = "zero"
14 | oneKey = "one"
15 | otherKey = "other"
16 | )
17 |
18 | var rules = map[string]PluralRule{
19 | // Most languages can use this rule
20 | DefaultRuleKey: func(d *Dict, n int) *Dict {
21 | if n == 0 {
22 | v := d.Get(zeroKey)
23 | if v != nil {
24 | return v
25 | }
26 | }
27 | if n == 1 {
28 | return d.Get(oneKey)
29 | }
30 | return d.Get(otherKey)
31 | },
32 | }
33 |
34 | // GetRule provides the PluralRule for the given key.
35 | func GetRule(key string) PluralRule {
36 | return rules[key]
37 | }
38 |
39 | // mapPluralRule is used to map a language code into a pluralization rule.
40 | func mapPluralRule(_ Code) PluralRule {
41 | return rules[DefaultRuleKey]
42 | }
43 |
--------------------------------------------------------------------------------
/i18n/plural_rules_test.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestZeroOneOtherRule(t *testing.T) {
10 | d := &Dict{
11 | entries: map[string]*Dict{
12 | "zero": {value: "no mice"},
13 | "one": {value: "%{count} mouse"},
14 | "other": {value: "%{count} mice"},
15 | },
16 | }
17 | rule := GetRule(DefaultRuleKey)
18 | assert.NotNil(t, rule)
19 | assert.Equal(t, "no mice", rule(d, 0).Value())
20 | assert.Equal(t, "%{count} mouse", rule(d, 1).Value())
21 | assert.Equal(t, "%{count} mice", rule(d, 2).Value())
22 |
23 | d = &Dict{
24 | entries: map[string]*Dict{
25 | "one": {value: "%{count} mouse"},
26 | "other": {value: "%{count} mice"},
27 | },
28 | }
29 | assert.Equal(t, "%{count} mice", rule(d, 0).Value())
30 | assert.Equal(t, "%{count} mouse", rule(d, 1).Value())
31 | assert.Equal(t, "%{count} mice", rule(d, 2).Value())
32 | }
33 |
--------------------------------------------------------------------------------
/internal/examples/en/en.yaml:
--------------------------------------------------------------------------------
1 | en:
2 | welcome_message: "Welcome to our application!"
3 | login:
4 | button: "Log In"
5 | signup-button: "Sign Up"
6 | forgot-password: "Forgot Password?"
7 | contact_us: "Contact Us"
8 | about_us: "About Us"
9 | terms_of_service: "Terms of Service"
10 | privacy_policy: "Privacy Policy"
11 | special_label: "Special Label"
12 |
--------------------------------------------------------------------------------
/internal/examples/en/ext.json:
--------------------------------------------------------------------------------
1 | {
2 | "en": {
3 | "ext": {
4 | "test": "Extensions"
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/internal/examples/es/es.yaml:
--------------------------------------------------------------------------------
1 | es:
2 | welcome_message: "Bienvenido"
3 | login:
4 | button: "Iniciar Sesión"
5 | signup-button: "Registrarse"
6 | forgot-password: "¿Olvidaste tu contraseña?"
7 | contact_us: "Contáctenos"
8 | about_us: "Sobre Nosotros"
9 | terms_of_service: "Términos de Servicio"
10 | privacy_policy: "Política de Privacidad"
11 |
--------------------------------------------------------------------------------
/internal/examples/examples.go:
--------------------------------------------------------------------------------
1 | package examples
2 |
3 | import "embed"
4 |
5 | //go:embed en
6 | //go:embed es
7 | //go:embed ignore.txt
8 |
9 | // Content contains the example locale files
10 | var Content embed.FS
11 |
--------------------------------------------------------------------------------
/internal/examples/ignore.txt:
--------------------------------------------------------------------------------
1 | Just a text file to ignore.
--------------------------------------------------------------------------------