├── .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 | [![Lint](https://github.com/invopop/ctxi18n/actions/workflows/lint.yaml/badge.svg)](https://github.com/invopop/ctxi18n/actions/workflows/lint.yaml) 4 | [![Test Go](https://github.com/invopop/ctxi18n/actions/workflows/test.yaml/badge.svg)](https://github.com/invopop/ctxi18n/actions/workflows/test.yaml) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/invopop/ctxi18n)](https://goreportcard.com/report/github.com/invopop/ctxi18n) 6 | [![codecov](https://codecov.io/gh/invopop/ctxi18n/graph/badge.svg?token=17H4KY3CNZ)](https://codecov.io/gh/invopop/ctxi18n) 7 | [![GoDoc](https://godoc.org/github.com/invopop/ctxi18n?status.svg)](https://godoc.org/github.com/invopop/ctxi18n) 8 | ![Latest Tag](https://img.shields.io/github/v/tag/invopop/ctxi18n) 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. --------------------------------------------------------------------------------