├── .github ├── FUNDING.yml ├── assets │ ├── cover.png │ ├── type-safe.mp4 │ └── type-safe.png └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── eslint.config.js ├── package.json ├── playground ├── i18n.d.ts ├── index.ts ├── locales │ ├── en.json │ └── tr.json ├── package.json ├── tsconfig.json └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json ├── src ├── i18n.ts ├── index.ts ├── plugin.ts ├── types.ts ├── unplugin │ ├── core │ │ ├── generate.ts │ │ ├── jsonToTS.ts │ │ └── unplugin.ts │ ├── esbuild.ts │ ├── index.ts │ ├── nuxt.ts │ ├── rollup.ts │ ├── types.ts │ ├── vite.ts │ └── webpack.ts └── utils │ └── index.ts ├── test ├── .cache │ ├── i18n.d.ts │ └── locales │ │ ├── en.json │ │ └── tr.json ├── customPluralRules.test.ts ├── general.test.ts ├── locale-specific.test.ts ├── t.test.ts ├── transformPhrase.test.ts └── type.test.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: productdevbook -------------------------------------------------------------------------------- /.github/assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/productdevbookcom/ts-i18n/7041520ce1dba799c79821e9093316693fb783e2/.github/assets/cover.png -------------------------------------------------------------------------------- /.github/assets/type-safe.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/productdevbookcom/ts-i18n/7041520ce1dba799c79821e9093316693fb783e2/.github/assets/type-safe.mp4 -------------------------------------------------------------------------------- /.github/assets/type-safe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/productdevbookcom/ts-i18n/7041520ce1dba799c79821e9093316693fb783e2/.github/assets/type-safe.png -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | permissions: 8 | pull-requests: write 9 | contents: read 10 | 11 | on: 12 | push: 13 | branches: 14 | - main 15 | 16 | pull_request: 17 | branches: 18 | - main 19 | 20 | jobs: 21 | build-test: 22 | name: 📚 Main 23 | runs-on: ${{ matrix.os }} 24 | 25 | strategy: 26 | matrix: 27 | os: [ubuntu-latest] 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - run: corepack enable 33 | - uses: actions/setup-node@v3 34 | with: 35 | node-version: '18' 36 | cache: pnpm 37 | 38 | - name: 📦 Install dependencies 39 | run: pnpm install --frozen-lockfile 40 | 41 | - name: 👀 Lint 42 | run: pnpm lint 43 | 44 | - name: 🚀 Build 45 | run: pnpm build 46 | 47 | # - name: 🧪 Test 48 | # run: pnpm test 49 | # env: 50 | # VITE_TEST_DB_URL: ${{ secrets.VITE_TEST_DB_URL }} 51 | 52 | - name: 🧪 Test with coverage 53 | run: pnpm coverage 54 | 55 | - name: 📝 Upload coverage 56 | if: always() 57 | uses: davelosert/vitest-coverage-report-action@v2 58 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | pull-requests: write 10 | contents: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 20.x 23 | 24 | - run: npx changelogithub 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | package-lock.json -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts=true 2 | shamefully-hoist=true 3 | git-checks=false -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | "eslint.experimental.useFlatConfig": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 productdevbook - Open Source 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TS I18n 2 | 3 | ![@productdevbook/ts-i18n](./.github/assets/cover.png) 4 | 5 | 6 |

7 | Version 8 | Downloads 9 | License 10 | 11 | Github Stars 12 | Discord 13 |

14 | 15 | "ts-i18n" is a Typescript library that facilitates internationalization (i18n) in both browser and ES module environments (Node). It offers a lightweight solution for interpolation and pluralization. 16 | 17 | Unlike some i18n libraries, ts-i18n doesn't handle the actual translation of phrases. Instead, it provides tools for managing translated phrases within your client or server-side Typescript application, making it agnostic to the translation backend used." 18 | 19 | ## Features 20 | 21 | - Typescript support 22 | - Node.js Version >= 18.0.0 23 | - ES module support 24 | - Type Safe and Auto Complete 25 | - Unplugin TS Safe 26 | 27 | ## Sponsors 28 | 29 |

30 | 31 | sponsors 32 | 33 |

34 | 35 | 36 | ## Installation 37 | 38 | ```sh 39 | pnpm add @productdevbook/ts-i18n 40 | ``` 41 | 42 | ## Usage 43 | 44 | ### Translation 45 | Tell Polyglot what to say by simply giving it a phrases object, where the key is the canonical name of the phrase and the value is the already-translated string. 46 | 47 | ```ts 48 | import { Polyglot } from '@productdevbook/ts-i18n' 49 | 50 | const polyglot = new Polyglot({ 51 | locale: 'en', 52 | }) 53 | 54 | polyglot.extend({ 55 | hello: 'Hello' 56 | }) 57 | 58 | polyglot.t('hello') // Hello 59 | ``` 60 | You can also pass a mapping at instantiation, using the key phrases: 61 | 62 | ```ts 63 | const polyglot = new Polyglot({ phrases: { hello: 'Hello' } }) 64 | ``` 65 | 66 | Polyglot doesn’t do the translation for you. It’s up to you to give it the proper phrases for the user’s locale. 67 | 68 | 69 | ## Type Safety - Unplugin 70 | 71 | This structure allows you to convert a given json file to TS and creates the interface for ts-i18n. The resulting file then needs to be imported instead of `new Polyglot` `T`. 72 | 73 |
74 | Vite
75 | 76 | ```ts 77 | // vite.config.ts 78 | import TSI18n from '@productdevbook/ts-i18n/vite' 79 | 80 | export default defineConfig({ 81 | plugins: [ 82 | TSI18n({ 83 | exportFilePath: './i18n.d.ts', 84 | localesFolder: 'locales', 85 | selectLanguage: 'en', 86 | }), 87 | ], 88 | }) 89 | ``` 90 | 91 |
92 | 93 |
94 | Webpack
95 | 96 | ```ts 97 | // webpack.config.ts 98 | module.exports = { 99 | /* ... */ 100 | plugins: [ 101 | require('@productdevbook/ts-i18n/webpack')({ 102 | exportFilePath: './i18n.d.ts', 103 | localesFolder: 'locales', 104 | selectLanguage: 'en', 105 | }), 106 | ], 107 | } 108 | ``` 109 | 110 |
111 | 112 | 113 |
114 | Rollup
115 | 116 | ```ts 117 | // rollup.config.js 118 | import TSI18n from '@productdevbook/ts-i18n/rollup' 119 | 120 | export default { 121 | plugins: [ 122 | TSI18n({ 123 | exportFilePath: './i18n.d.ts', 124 | localesFolder: 'locales', 125 | selectLanguage: 'en', 126 | }), 127 | ], 128 | } 129 | ``` 130 | 131 |
132 | 133 |
134 | ESBuild
135 | 136 | ```ts 137 | // esbuild.config.js 138 | // esbuild.config.js 139 | import { build } from 'esbuild' 140 | 141 | build({ 142 | /* ... */ 143 | plugins: [ 144 | require('@productdevbook/ts-i18n/esbuild')({ 145 | exportFilePath: './i18n.d.ts', 146 | localesFolder: 'locales', 147 | selectLanguage: 'en', 148 | }), 149 | ], 150 | }) 151 | ``` 152 | 153 |
154 | 155 |
156 | Nuxt
157 | 158 | 159 | You might not need this plugin for Nuxt. Use `@productdevbook/ts-i18n/nuxt` instead. 160 | 161 | ```ts 162 | // nuxt.config.ts 163 | 164 | export default defineNuxtConfig({ 165 | modules: [ 166 | '@productdevbook/ts-i18n/nuxt', 167 | ], 168 | 169 | tsI18n: { 170 | exportFilePath: './i18n.d.ts', 171 | localesFolder: 'locales', 172 | selectLanguage: 'en', 173 | }, 174 | }) 175 | ``` 176 | 177 |
178 | 179 | 180 | ## Locale Files 181 | If you tell it which folder the language files are in, it will automatically fetch the language according to that locale and get the values from it. 182 | 183 | ```ts 184 | import { Polyglot } from '@productdevbook/ts-i18n' 185 | import type { I18nTranslations } from './i18n' 186 | 187 | const i18n = new Polyglot({ 188 | locale: 'en', 189 | loaderOptions: { 190 | path: 'locales', 191 | }, 192 | }) 193 | 194 | i18n.t('hello') // Hello 195 | 196 | const i18n = new Polyglot({ 197 | locale: 'tr', 198 | loaderOptions: { 199 | path: 'locales', 200 | }, 201 | }) 202 | 203 | i18n.t('hello') // Merhaba 204 | ``` 205 | 206 | ### Error Missing Translation 207 | 208 | If you want to throw an error when a translation is missing, you can use the `errorOnMissing` option. 209 | 210 | ```ts 211 | const i18n = new Polyglot({ 212 | errorOnMissing: true, 213 | }) 214 | ``` 215 | 216 | ### Interpolation 217 | `Polyglot.t()` also provides interpolation. Pass an object with key-value pairs of interpolation arguments as the second parameter. 218 | 219 | ```ts 220 | import { Polyglot } from '@productdevbook/ts-i18n' 221 | 222 | const polyglot = new Polyglot({ 223 | locale: 'en', 224 | phrases: { 225 | hello_name: 'Hello %{name}', 226 | }, 227 | }) 228 | 229 | polyglot.extend({ 230 | hello_name: 'Hola, %{name}.' 231 | }) 232 | 233 | polyglot.t('hello_name', { name: 'DeNiro' }) // Hola, DeNiro. 234 | ``` 235 | 236 | Polyglot also supports nested phrase objects. 237 | 238 | ```ts 239 | polyglot.extend({ 240 | nav: { 241 | hello: 'Hello', 242 | hello_name: 'Hello, %{name}', 243 | sidebar: { 244 | welcome: 'Welcome' 245 | } 246 | } 247 | }) 248 | 249 | polyglot.t('nav.sidebar.welcome') // Welcome 250 | ``` 251 | 252 | The substitution variable syntax is customizable. 253 | 254 | ```ts 255 | const polyglot = new Polyglot({ 256 | locale: 'en', 257 | phrases: { 258 | hello_name: 'Hola {{name}}' 259 | }, 260 | interpolation: { prefix: '{{', suffix: '}}' } 261 | }) 262 | 263 | polyglot.t('hello_name', { name: 'DeNiro' }) // Hola DeNiro 264 | ``` 265 | 266 | ### Pluralization 267 | 268 | For pluralization to work properly, you need to tell Polyglot what the current locale is. You can use `polyglot.locale("tr")` to set the locale to, for example, Turkish. This method is also a getter: 269 | 270 | ```ts 271 | polyglot.locale() // tr 272 | ``` 273 | You can also pass this in during instantiation. 274 | 275 | ```ts 276 | const polyglot = new Polyglot({ locale: 'tr' }) 277 | ``` 278 | 279 | Currently, the only thing that Polyglot uses this locale setting for is pluralization. 280 | 281 | Polyglot provides a very basic pattern for providing pluralization based on a single string that contains all plural forms for a given phrase. Because various languages have different nominal forms for zero, one, and multiple, and because the noun can be before or after the count, we have to be overly explicit about the possible phrases. 282 | 283 | To get a pluralized phrase, still use `polyglot.t()` but use a specially-formatted phrase string that separates the plural forms by the delimiter `||||`, or four vertical pipe characters. 284 | 285 | For pluralizing "car" in English, Polyglot assumes you have a phrase of the form: 286 | 287 | ```ts 288 | polyglot.extend({ 289 | num_cars: '%{smart_count} car |||| %{smart_count} cars', 290 | }) 291 | ``` 292 | 293 | Please keep in mind that `smart_count` is required. No other option name is taken into account to transform pluralization strings. 294 | 295 | In English (and German, Spanish, Italian, and a few others) there are only two plural forms: singular and not-singular. 296 | 297 | Some languages get a bit more complicated. In Czech, there are three separate forms: 1, 2 through 4, and 5 and up. Russian is even more involved. 298 | 299 | ```ts 300 | const polyglot = new Polyglot({ locale: 'cs' }) // Czech 301 | polyglot.extend({ 302 | num_foxes: 'Mám %{smart_count} lišku |||| Mám %{smart_count} lišky |||| Mám %{smart_count} lišek' 303 | }) 304 | ``` 305 | polyglot.t() will choose the appropriate phrase based on the provided `smart_count` option, whose value is a number. 306 | 307 | ```ts 308 | polyglot.t('num_cars', { smart_count: 0 }) // 0 cars 309 | 310 | polyglot.t('num_cars', { smart_count: 1 }) // 1 car 311 | 312 | polyglot.t('num_cars', { smart_count: 2 }) // 2 cars 313 | ``` 314 | 315 | As a shortcut, you can also pass a number to the second parameter: 316 | 317 | ```ts 318 | polyglot.t('num_cars', 2) // 2 cars 319 | ``` 320 | 321 | ### Custom Pluralization Rules 322 | 323 | Polyglot provides some default pluralization rules for some locales. You can specify a different set of rules through the pluralRules constructor param. 324 | 325 | ```ts 326 | const polyglot = new Polyglot({ 327 | locale: 'en', 328 | pluralRules: { 329 | pluralTypes: { 330 | germanLike(n) { 331 | // is 1 332 | if (n === 1) 333 | return 0 334 | 335 | // everything else 336 | return 1 337 | }, 338 | frenchLike(n) { 339 | // is 0 or 1 340 | if (n <= 1) 341 | return 0 342 | 343 | // everything else 344 | return 1 345 | } 346 | }, 347 | pluralTypeToLanguages: { 348 | germanLike: ['de', 'en', 'xh', 'zu'], 349 | frenchLike: ['fr', 'hy'] 350 | } 351 | } 352 | }) 353 | ``` 354 | 355 | This can be useful to support locales that polyglot does not support by default or to change the rule definitions. 356 | 357 | ## Public Instance Methods 358 | 359 | ### Polyglot.t(key, interpolationOptions) 360 | 361 | The most-used method. Provide a key, and t() will return the phrase. 362 | 363 | ```ts 364 | polyglot.t('hello') // Hello 365 | ``` 366 | The phrase value is provided first by a call to polyglot.extend() or polyglot.replace(). 367 | 368 | Pass in an object as the second argument to perform interpolation. 369 | 370 | ```ts 371 | polyglot.t('hello_name', { name: 'Spike' }) // Hello, Spike. 372 | ``` 373 | Pass a number as the second argument as a shortcut to `smart_count`: 374 | 375 | ```ts 376 | // same as: polyglot.t("car", {smart_count: 2}); 377 | polyglot.t('car', 2) // 2 cars 378 | ``` 379 | If you like, you can provide a default value in case the phrase is missing. Use the special option key "_" to specify a default. 380 | 381 | ```ts 382 | polyglot.t('i_like_to_write_in_language', { 383 | _: 'I like to write in %{language}.', 384 | language: 'JavaScript' 385 | }) // I like to write in JavaScript. 386 | ``` 387 | 388 | ### Polyglot.extend(phrases) 389 | 390 | Use extend to tell Polyglot how to translate a given key. 391 | 392 | ```ts 393 | polyglot.extend({ 394 | hello: 'Hello', 395 | hello_name: 'Hello, %{name}' 396 | }) 397 | ``` 398 | The key can be any string. Feel free to call extend multiple times; it will override any phrases with the same key, but leave existing phrases untouched. 399 | 400 | ### Polyglot.unset(keyOrObject) 401 | 402 | Use unset to selectively remove keys from a polyglot instance. unset accepts one argument: either a single string key, or an object whose keys are string keys, and whose values are ignored unless they are nested objects (in the same format). 403 | 404 | Example: 405 | 406 | ```ts 407 | polyglot.unset('some_key') 408 | polyglot.unset({ 409 | hello: 'Hello', 410 | hello_name: 'Hello, %{name}', 411 | foo: { 412 | bar: 'This phrase’s key is "foo.bar"' 413 | } 414 | }) 415 | ``` 416 | 417 | ### Polyglot.locale([localeToSet]) 418 | Get or set the locale (also can be set using the constructor option, which is used only for pluralization. If a truthy value is provided, it will set the locale. Afterwards, it will return it. 419 | 420 | ### Polyglot.clear() 421 | Clears all phrases. Useful for special cases, such as freeing up memory if you have lots of phrases but no longer need to perform any translation. Also used internally by replace. 422 | 423 | ### Polyglot.replace(phrases) 424 | Completely replace the existing phrases with a new set of phrases. Normally, just use extend to add more phrases, but under certain circumstances, you may want to make sure no old phrases are lying around. 425 | 426 | ### Polyglot.has(key) 427 | Returns `true` if the key does exist in the provided phrases, otherwise it will return `false`. 428 | 429 | ## Public Static Methods 430 | 431 | ### transformPhrase(phrase[, substitutions[, locale]]) 432 | 433 | Takes a phrase string and transforms it by choosing the correct plural form and interpolating it. This method is used internally by `t`. The correct plural form is selected if substitutions.smart_count is set. You can pass in a number instead of an Object as `substitutions` as a shortcut for `smart_count`. You should pass in a third argument, the locale, to specify the correct plural type. It defaults to `'en'` which has 2 plural forms. 434 | 435 | 436 | ## Development 437 | 438 | 1. Run `pnpm install` to install the dependencies. 439 | 2. Run `pnpm dev` to start the bundle. 440 | 3. Run `pnpm lint` to lint the code. (You can also run `pnpm lint:fix` to fix the linting errors.) 441 | 4. Run `pnpm test` to run the tests. (You can also run `pnpm test:watch` to run the tests in watch mode.) 442 | 443 | 444 | ## TODO 445 | 446 | - [ ] Add more tests 447 | - [ ] Add more documentation 448 | - [ ] Add more examples 449 | - [ ] Path auto language files import 450 | 451 | ## Source 452 | 453 | The project will continue by translating TS from [airbnb polyglot.js](https://github.com/airbnb/polyglot.js) codes and adding additional features. The codes in some places have changed. Thank you airbnb. :heart: 454 | 455 | ## Thanks 456 | 457 | - Type safety is inspired by [nestjs-i18n](https://github.com/toonvanstrijp/nestjs-i18n/blob/main/src/utils/typescript.ts). Thank you @toonvanstrijp. :heart: 458 | 459 | ## License 460 | 461 | This project is licensed under the [MIT License](LICENSE). 462 | 463 | [productdevbook team](https://github.com/productdevbookcom) 464 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu( 4 | {}, 5 | { 6 | ignores: [ 7 | 'dist', 8 | '.github', 9 | ], 10 | }, 11 | ) 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@productdevbook/ts-i18n", 3 | "type": "module", 4 | "version": "1.4.1", 5 | "packageManager": "pnpm@8.7.0", 6 | "description": "Give your JavaScript the ability to speak many languages.", 7 | "author": "Mehmet @productdevbook", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/productdevbook", 10 | "homepage": "https://github.com/productdevbookcom/ts-i18n", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/productdevbookcom/ts-i18n.git" 14 | }, 15 | "bugs": "https://github.com/productdevbookcom/ts-i18n/issues", 16 | "keywords": [ 17 | "i18n", 18 | "internationalization", 19 | "internationalisation", 20 | "translation", 21 | "interpolation", 22 | "translate", 23 | "polyglot", 24 | "typescript i18n", 25 | "backend i18n" 26 | ], 27 | "exports": { 28 | ".": { 29 | "import": "./dist/index.js", 30 | "require": "./dist/index.cjs" 31 | }, 32 | "./vite": { 33 | "import": { 34 | "types": "./dist/unplugin/vite.d.ts", 35 | "default": "./dist/unplugin/vite.js" 36 | }, 37 | "require": { 38 | "types": "./dist/unplugin/vite.d.cts", 39 | "default": "./dist/unplugin/vite.cjs" 40 | } 41 | }, 42 | "./esbuild": { 43 | "import": { 44 | "types": "./dist/unplugin/esbuild.d.ts", 45 | "default": "./dist/unplugin/esbuild.js" 46 | }, 47 | "require": { 48 | "types": "./dist/unplugin/esbuild.d.cts", 49 | "default": "./dist/unplugin/esbuild.cjs" 50 | } 51 | }, 52 | "./rollup": { 53 | "import": { 54 | "types": "./dist/unplugin/rollup.d.ts", 55 | "default": "./dist/unplugin/rollup.js" 56 | }, 57 | "require": { 58 | "types": "./dist/unplugin/rollup.d.cts", 59 | "default": "./dist/unplugin/rollup.cjs" 60 | } 61 | }, 62 | "./webpack": { 63 | "import": { 64 | "types": "./dist/unplugin/webpack.d.ts", 65 | "default": "./dist/unplugin/webpack.js" 66 | }, 67 | "require": { 68 | "types": "./dist/unplugin/webpack.d.cts", 69 | "default": "./dist/unplugin/webpack.cjs" 70 | } 71 | }, 72 | "./nuxt": { 73 | "import": { 74 | "types": "./dist/unplugin/nuxt.d.ts", 75 | "default": "./dist/unplugin/nuxt.js" 76 | }, 77 | "require": { 78 | "types": "./dist/unplugin/nuxt.d.cts", 79 | "default": "./dist/unplugin/nuxt.cjs" 80 | } 81 | }, 82 | "./*": "./*" 83 | }, 84 | "main": "./dist/index.js", 85 | "module": "./dist/index.js", 86 | "types": "./dist/index.d.ts", 87 | "typesVersions": { 88 | "*": { 89 | "*": [ 90 | "./dist/*", 91 | "./dist/unplugin/*", 92 | "./*" 93 | ] 94 | } 95 | }, 96 | "files": [ 97 | "dist" 98 | ], 99 | "engines": { 100 | "node": ">=18" 101 | }, 102 | "scripts": { 103 | "build": "tsup --dts", 104 | "dev": "tsup --watch", 105 | "prepublishOnly": "pnpm run build", 106 | "release": "pnpm build && bumpp --commit --push --tag && pnpm publish", 107 | "lint": "eslint .", 108 | "lint:fix": "eslint . --fix", 109 | "test": "vitest", 110 | "test:watch": "vitest --watch", 111 | "coverage": "vitest run --coverage" 112 | }, 113 | "peerDependencies": { 114 | "@nuxt/kit": "^3", 115 | "@nuxt/schema": "^3", 116 | "esbuild": "*", 117 | "rollup": "^3", 118 | "typescript": "^5", 119 | "vite": ">=3", 120 | "webpack": "^4 || ^5" 121 | }, 122 | "peerDependenciesMeta": { 123 | "webpack": { 124 | "optional": true 125 | }, 126 | "rollup": { 127 | "optional": true 128 | }, 129 | "vite": { 130 | "optional": true 131 | }, 132 | "esbuild": { 133 | "optional": true 134 | }, 135 | "@nuxt/kit": { 136 | "optional": true 137 | }, 138 | "@nuxt/schema": { 139 | "optional": true 140 | } 141 | }, 142 | "dependencies": { 143 | "unplugin": "^1.5.0" 144 | }, 145 | "devDependencies": { 146 | "@antfu/eslint-config": "1.0.0-beta.19", 147 | "@nuxt/kit": "^3.7.4", 148 | "@nuxt/schema": "^3.7.4", 149 | "@types/has": "^1.0.0", 150 | "@types/iterate-iterator": "^1.0.0", 151 | "@types/node": "^20.8.2", 152 | "@vitest/coverage-v8": "^0.34.6", 153 | "bumpp": "^9.2.0", 154 | "eslint": "^8.50.0", 155 | "iterate-iterator": "^1.0.2", 156 | "lint-staged": "^14.0.1", 157 | "simple-git-hooks": "^2.9.0", 158 | "tsup": "^7.2.0", 159 | "typescript": "^5.2.2", 160 | "vite": "^4.4.10", 161 | "vitest": "^0.34.6" 162 | }, 163 | "publishConfig": { 164 | "access": "public" 165 | }, 166 | "simple-git-hooks": { 167 | "pre-commit": "pnpm lint-staged" 168 | }, 169 | "lint-staged": { 170 | "*": "eslint . --fix" 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /playground/i18n.d.ts: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT, file generated by @productdevbook/ts-i18n */ 2 | /* eslint-disable eslint-comments/no-unlimited-disable */ 3 | /* eslint-disable */ 4 | /* prettier-ignore */ 5 | // @ts-ignore 6 | 7 | import { Path } from "@productdevbook/ts-i18n"; 8 | export type I18nTranslations = { 9 | "hello": string; 10 | "foo": string; 11 | "hello2": string; 12 | "headeraaa": { 13 | "x-foo": { 14 | "variables": { 15 | "tttt": string; 16 | "bbb": string; 17 | }; 18 | }; 19 | }; 20 | }; 21 | export type I18nPath = Path; 22 | -------------------------------------------------------------------------------- /playground/index.ts: -------------------------------------------------------------------------------- 1 | import { Polyglot } from '@productdevbook/ts-i18n' 2 | import type { I18nTranslations } from './i18n' 3 | 4 | const polyglot = new Polyglot({ 5 | locale: 'tr', 6 | loaderOptions: { 7 | path: 'locales', 8 | }, 9 | errorOnMissing: true, 10 | }) 11 | 12 | polyglot.extend() 13 | 14 | // eslint-disable-next-line no-console 15 | console.log(polyglot.t('headeraaa.x-foo', { tttt: '123123123' })) // Hello 16 | -------------------------------------------------------------------------------- /playground/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "world", 3 | "foo": "bar", 4 | "headeraaa": { 5 | "x-foo": "bar %{tttt} %{bbb}" 6 | }, 7 | "hello2": "world2" 8 | } 9 | -------------------------------------------------------------------------------- /playground/locales/tr.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "world", 3 | "foo": "bar", 4 | "headeraaa": { 5 | "x-foo": "bar %{tttt} %{bbb}" 6 | }, 7 | "hello2": "world2" 8 | } 9 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "author": "", 6 | "license": "ISC", 7 | "keywords": [], 8 | "main": "index.ts", 9 | "scripts": { 10 | "dev": "jiti index.ts", 11 | "dev:vite": "nodemon -w '../src/**/*.ts' -e .ts -x vite" 12 | }, 13 | "dependencies": { 14 | "@productdevbook/ts-i18n": "workspace:^", 15 | "jiti": "^1.20.0" 16 | }, 17 | "devDependencies": { 18 | "nodemon": "^3.0.1", 19 | "typescript": "^5.2.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Node 16", 4 | "compilerOptions": { 5 | "target": "es2021", 6 | "lib": [ 7 | "es2021" 8 | ], 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "strict": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "skipLibCheck": true 16 | }, 17 | "include": [ 18 | "./**/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import Unplugin from '@productdevbook/ts-i18n/vite' 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | Unplugin({ 7 | exportFilePath: 'i18n.d.ts', 8 | localesFolder: 'locales', 9 | selectLanguage: 'en', 10 | }), 11 | ], 12 | }) 13 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "rebaseWhen": "conflicted", 7 | "schedule": [ 8 | "every 8 months on the first day of the month" 9 | ], 10 | "baseBranches": [ 11 | "main" 12 | ], 13 | "rangeStrategy": "bump", 14 | "ignoreDeps": [ 15 | "node", 16 | "pnpm" 17 | ], 18 | "packageRules": [ 19 | { 20 | "enabled": false, 21 | "matchDepTypes": [ 22 | "peerDependencies" 23 | ] 24 | }, 25 | { 26 | "groupName": "playground", 27 | "commitMessageTopic": "playground", 28 | "matchPaths": [ 29 | "playground/**" 30 | ], 31 | "matchUpdateTypes": [ 32 | "major", 33 | "minor", 34 | "patch" 35 | ], 36 | "matchDatasources": [ 37 | "npm" 38 | ] 39 | }, 40 | { 41 | "groupName": "root", 42 | "matchUpdateTypes": [ 43 | "patch", 44 | "minor", 45 | "major" 46 | ], 47 | "ignorePaths": [ 48 | "**/playground/**" 49 | ], 50 | "matchDatasources": [ 51 | "npm", 52 | "github-actions" 53 | ], 54 | "labels": [ 55 | "dependencies" 56 | ], 57 | "addLabels": [ 58 | "dependencies" 59 | ], 60 | "matchFiles": [ 61 | "package.json" 62 | ] 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | import type { Dirent } from 'node:fs' 2 | import { existsSync, readFileSync, readdirSync } from 'node:fs' 3 | import { extname, join } from 'node:path' 4 | 5 | const ACCEPTED_EXTENSIONS = ['.json'] 6 | 7 | export function getLocales(basepath: string, defaultLocale: string) { 8 | if (!basepath || !existsSync(basepath)) { 9 | console.warn('missing or invalid locales folder') 10 | return {} 11 | } 12 | let contents: any[] = [] 13 | let ext: string 14 | try { 15 | contents = readdirSync(basepath, { withFileTypes: true }) 16 | } 17 | catch (err: any) { 18 | console.warn(err) 19 | } 20 | return contents 21 | .filter((entry: Dirent) => { 22 | ext = extname(entry.name) 23 | return (entry.isFile() && ACCEPTED_EXTENSIONS.includes(ext) && entry.name === defaultLocale + ext) 24 | }) 25 | .reduce((locales, entry) => { 26 | const name = entry.name.replace(/\.[^/.]+$/, '') 27 | const pathname = join(basepath, name) 28 | if (pathname) { 29 | return readFileSync(pathname + ext, 'utf8') 30 | } 31 | else { 32 | console.warn(`Missing locale: ${pathname}`) 33 | return {} 34 | } 35 | }, {}) 36 | } 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { InterpolationOptions, InterpolationTokenOptions, PluralRules, PolyglotOptions } from './plugin' 2 | export { Polyglot } from './plugin' 3 | export { forEach } from './utils' 4 | export type { Path, PathValue } from './types' 5 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { getLocales } from './i18n' 2 | import type { IfAnyOrNever, Path, PathValue } from './types' 3 | import { warn } from './utils' 4 | 5 | export interface InterpolationOptions { 6 | smart_count?: number | { length: number } | undefined 7 | _?: string | undefined 8 | 9 | [interpolationKey: string]: any 10 | } 11 | 12 | export interface InterpolationTokenOptions { 13 | prefix?: string | undefined 14 | suffix?: string | undefined 15 | } 16 | 17 | export interface PluralRules { 18 | pluralTypes: { [lang: string]: (n: number) => number } 19 | pluralTypeToLanguages: { [lang: string]: string[] } 20 | } 21 | 22 | export interface PolyglotOptions { 23 | /** 24 | * The locale to use. If `loaderOptions` used this language you must use same filename. 25 | * @default en 26 | * @example 'en' 27 | */ 28 | locale: string | undefined 29 | 30 | /** 31 | * The phrases to translate. 32 | * @default {} 33 | * @example { hello: 'Hello' } 34 | * @example { hello: 'Hello', hi_name_welcome_to_place: 'Hi, %{name}, welcome to %{place}!' } 35 | */ 36 | phrases?: any 37 | 38 | allowMissing?: boolean | undefined 39 | onMissingKey?: ((key: string, options: InterpolationOptions, locale: string, tokenRegex: RegExp, pluralRules: PluralRules | undefined, replaceImplementation: any) => string) | null | undefined 40 | warn?: ((message: string) => void) | undefined | any 41 | interpolation?: InterpolationTokenOptions | undefined 42 | pluralRules?: PluralRules | undefined 43 | replace?: (searchValue: RegExp, replaceValue: any) => any | undefined 44 | 45 | /** 46 | * Safe TypeScript types for translations. 47 | * @example { 48 | * path: 'locales', 49 | * } 50 | */ 51 | loaderOptions?: { 52 | /** 53 | * The default locale to use. 54 | * @example 'locales' 55 | */ 56 | path: string 57 | } 58 | 59 | /** 60 | * @default false 61 | */ 62 | errorOnMissing?: boolean | undefined 63 | } 64 | 65 | const defaultReplace = String.prototype.replace 66 | 67 | const delimiter = '||||' 68 | 69 | const russianPluralGroups = function (n: number): number { 70 | const lastTwo = n % 100 71 | const end = lastTwo % 10 72 | if (lastTwo !== 11 && end === 1) 73 | return 0 74 | 75 | if (end >= 2 && end <= 4 && !(lastTwo >= 12 && lastTwo <= 14)) 76 | return 1 77 | 78 | return 2 79 | } 80 | 81 | const defaultPluralRules: PluralRules = { 82 | // Mapping from pluralization group plural logic. 83 | pluralTypes: { 84 | arabic(n: number): number { 85 | // http://www.arabeyes.org/Plural_Forms 86 | if (n < 3) 87 | return n 88 | const lastTwo = n % 100 89 | if (lastTwo >= 3 && lastTwo <= 10) 90 | return 3 91 | return lastTwo >= 11 ? 4 : 5 92 | }, 93 | bosnian_serbian: russianPluralGroups, 94 | chinese() { return 0 }, 95 | croatian: russianPluralGroups, 96 | french(n: number): number { return n >= 2 ? 1 : 0 }, 97 | german(n: number): number { return n !== 1 ? 1 : 0 }, 98 | russian: russianPluralGroups, 99 | lithuanian(n: number): number { 100 | if (n % 10 === 1 && n % 100 !== 11) 101 | return 0 102 | return (n % 10 >= 2 && n % 10 <= 9 && (n % 100 < 11 || n % 100 > 19)) ? 1 : 2 103 | }, 104 | czech(n: number): number { 105 | if (n === 1) 106 | return 0 107 | return (n >= 2 && n <= 4) ? 1 : 2 108 | }, 109 | polish(n: number): number { 110 | if (n === 1) 111 | return 0 112 | const end = n % 10 113 | return (end >= 2 && end <= 4 && (n % 100 < 10 || n % 100 >= 20)) ? 1 : 2 114 | }, 115 | icelandic(n: number): number { return (n % 10 !== 1 || n % 100 === 11) ? 1 : 0 }, 116 | slovenian(n: number): number { 117 | const lastTwo = n % 100 118 | if (lastTwo === 1) 119 | return 0 120 | 121 | if (lastTwo === 2) 122 | return 1 123 | 124 | if (lastTwo === 3 || lastTwo === 4) 125 | return 2 126 | 127 | return 3 128 | }, 129 | romanian(n: number): number { 130 | if (n === 1) 131 | return 0 132 | const lastTwo = n % 100 133 | if (n === 0 || (lastTwo >= 2 && lastTwo <= 19)) 134 | return 1 135 | return 2 136 | }, 137 | turkish(n: number): number { return n > 1 ? 1 : 0 }, 138 | }, 139 | 140 | // Mapping from pluralization group to individual language codes/locales. 141 | // Will look up based on exact match, if not found and it's a locale will parse the locale 142 | // for language code, and if that does not exist will default to 'en' 143 | pluralTypeToLanguages: { 144 | arabic: ['ar'], 145 | bosnian_serbian: ['bs-Latn-BA', 'bs-Cyrl-BA', 'srl-RS', 'sr-RS'], 146 | chinese: ['id', 'id-ID', 'ja', 'ko', 'ko-KR', 'lo', 'ms', 'th', 'th-TH', 'zh'], 147 | croatian: ['hr', 'hr-HR'], 148 | german: ['fa', 'da', 'de', 'en', 'es', 'fi', 'el', 'he', 'hi-IN', 'hu', 'hu-HU', 'it', 'nl', 'no', 'pt', 'sv', 'tr'], 149 | french: ['fr', 'tl', 'pt-br'], 150 | russian: ['ru', 'ru-RU'], 151 | lithuanian: ['lt'], 152 | czech: ['cs', 'cs-CZ', 'sk'], 153 | polish: ['pl'], 154 | icelandic: ['is', 'mk'], 155 | slovenian: ['sl-SL'], 156 | romanian: ['ro'], 157 | turkish: ['tr-TR', 'tr'], 158 | }, 159 | } 160 | 161 | function langToTypeMap(mapping: Record): Record { 162 | const ret: Record = {} 163 | for (const type in mapping) { 164 | const langs = mapping[type] 165 | for (const lang of langs) 166 | ret[lang] = type 167 | } 168 | 169 | return ret 170 | } 171 | 172 | function pluralTypeName(pluralRules: PluralRules, locale: string): string { 173 | const langToPluralType = langToTypeMap(pluralRules.pluralTypeToLanguages) 174 | return langToPluralType[locale] 175 | || langToPluralType[locale.split('-')[0]] 176 | || langToPluralType.en 177 | } 178 | 179 | function pluralTypeIndex(pluralRules: PluralRules, pluralType: string, count: number): number { 180 | return pluralRules.pluralTypes[pluralType](count) 181 | } 182 | 183 | function createMemoizedPluralTypeNameSelector(): (pluralRules: PluralRules, locale: string) => string | undefined { 184 | const localePluralTypeStorage: Record = {} 185 | 186 | return function (pluralRules: PluralRules, locale: string): string | undefined { 187 | let pluralType = localePluralTypeStorage[locale] 188 | 189 | if (pluralType && !pluralRules.pluralTypes[pluralType]) { 190 | pluralType = undefined 191 | localePluralTypeStorage[locale] = pluralType 192 | } 193 | 194 | if (!pluralType) { 195 | pluralType = pluralTypeName(pluralRules, locale) 196 | 197 | if (pluralType) 198 | localePluralTypeStorage[locale] = pluralType 199 | } 200 | 201 | return pluralType 202 | } 203 | } 204 | 205 | function escape(token: string): string { 206 | return token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 207 | } 208 | 209 | function constructTokenRegex(opts?: { prefix?: string; suffix?: string }): RegExp { 210 | const prefix = (opts && opts.prefix) || '%{' 211 | const suffix = (opts && opts.suffix) || '}' 212 | 213 | if (prefix === delimiter || suffix === delimiter) 214 | throw new RangeError(`"${delimiter}" token is reserved for pluralization`) 215 | 216 | return new RegExp(`${escape(prefix)}(.*?)${escape(suffix)}`, 'g') 217 | } 218 | 219 | const memoizedPluralTypeName = createMemoizedPluralTypeNameSelector() 220 | 221 | const defaultTokenRegex = /%\{(.*?)\}/gim 222 | 223 | function transformPhrase( 224 | phrase: string, 225 | substitutions?: number | Record, 226 | locale?: string, 227 | tokenRegex?: RegExp, 228 | pluralRules?: PluralRules | undefined, 229 | replaceImplementation?: PolyglotOptions['replace'] | undefined | any, 230 | ): string { 231 | if (typeof phrase !== 'string') 232 | throw new TypeError('Polyglot.transformPhrase expects argument #1 to be string') 233 | 234 | if (substitutions == null) 235 | return phrase 236 | 237 | let result = phrase 238 | const interpolationRegex = tokenRegex || defaultTokenRegex 239 | const replace = replaceImplementation || defaultReplace 240 | 241 | const options = typeof substitutions === 'number' ? { smart_count: substitutions } : substitutions 242 | 243 | if (options.smart_count != null && phrase) { 244 | const pluralRulesOrDefault = pluralRules || defaultPluralRules 245 | const texts = phrase.split(delimiter) 246 | const bestLocale = locale || 'en' 247 | const pluralType = memoizedPluralTypeName(pluralRulesOrDefault, bestLocale) 248 | const pluralTypeWithCount = pluralTypeIndex( 249 | pluralRulesOrDefault, 250 | pluralType || 'en', 251 | options.smart_count, 252 | ) 253 | 254 | result = (texts[pluralTypeWithCount] || texts[0]).trim() 255 | } 256 | 257 | result = replace.call(result, interpolationRegex, (expression: string, argument: string) => { 258 | if (options[argument] == null || options[argument] === null) 259 | return expression 260 | return options[argument] 261 | }) 262 | 263 | return result 264 | } 265 | 266 | type LocaleMessage = Record 267 | 268 | export interface DefineLocaleMessage extends LocaleMessage {} 269 | 270 | export class Polyglot { 271 | phrases: Record 272 | currentLocale: string 273 | onMissingKey: PolyglotOptions['onMissingKey'] | null 274 | warn: PolyglotOptions['warn'] | undefined 275 | replaceImplementation: PolyglotOptions['replace'] | undefined | any 276 | tokenRegex: RegExp 277 | pluralRules: PluralRules | undefined 278 | loaderOptions: PolyglotOptions['loaderOptions'] 279 | errorOnMissing: PolyglotOptions['errorOnMissing'] | undefined 280 | 281 | constructor(options: PolyglotOptions) { 282 | const opts = options || {} 283 | this.phrases = {} 284 | this.extend(opts.phrases || {}) 285 | this.currentLocale = opts.locale || 'en' 286 | const allowMissing = opts.allowMissing ? transformPhrase : null 287 | this.onMissingKey = typeof opts.onMissingKey === 'function' ? opts.onMissingKey : allowMissing 288 | this.warn = opts.warn || warn 289 | this.replaceImplementation = opts.replace || defaultReplace 290 | this.tokenRegex = constructTokenRegex(opts.interpolation) 291 | this.pluralRules = opts.pluralRules || defaultPluralRules 292 | this.errorOnMissing = opts.errorOnMissing || false 293 | 294 | if (opts.loaderOptions) { 295 | this.loaderOptions = opts.loaderOptions 296 | 297 | if (this.loaderOptions.path) { 298 | const lang = getLocales(this.loaderOptions.path, this.currentLocale) 299 | this.extend(JSON.parse(lang as any)) 300 | } 301 | } 302 | } 303 | 304 | locale(newLocale?: string) { 305 | if (newLocale) 306 | this.currentLocale = newLocale 307 | return this.currentLocale 308 | } 309 | 310 | extend(morePhrases?: any, prefix?: string) { 311 | for (const key in morePhrases) { 312 | const phrase = morePhrases[key] 313 | const prefixedKey = prefix ? `${prefix}.${key}` : key 314 | if (typeof phrase === 'object') 315 | this.extend(phrase, prefixedKey) 316 | 317 | else 318 | this.phrases[prefixedKey] = phrase 319 | } 320 | } 321 | 322 | unset(morePhrases?: any, prefix?: string) { 323 | if (typeof morePhrases === 'string') { 324 | delete this.phrases[morePhrases] 325 | } 326 | else { 327 | for (const key in morePhrases) { 328 | const phrase = morePhrases[key] 329 | const prefixedKey = prefix ? `${prefix}.${key}` : key 330 | if (typeof phrase === 'object') 331 | this.unset(phrase, prefixedKey) 332 | 333 | else 334 | delete this.phrases[prefixedKey] 335 | } 336 | } 337 | } 338 | 339 | clear() { 340 | this.phrases = {} 341 | } 342 | 343 | replace(newPhrases: Record | undefined | null) { 344 | this.clear() 345 | this.extend(newPhrases) 346 | } 347 | 348 | t

= any, R = PathValue>(key: P, options?: number | InterpolationOptions): IfAnyOrNever { 349 | let phrase: string | undefined 350 | let result: string | undefined 351 | const opts = options == null ? {} : options as InterpolationOptions 352 | if (typeof this.phrases[key] === 'string') { 353 | phrase = this.phrases[key] 354 | } 355 | else if (options !== null && options !== null && typeof opts._ === 'string') { 356 | phrase = opts._ 357 | } 358 | else if (this.onMissingKey) { 359 | const onMissingKey = this.onMissingKey 360 | result = onMissingKey( 361 | key, 362 | opts, 363 | this.currentLocale, 364 | this.tokenRegex, 365 | this.pluralRules, 366 | this.replaceImplementation, 367 | ) 368 | } 369 | else { 370 | this.warn(`Missing translation for key: "${key}"`) 371 | result = key 372 | } 373 | if (typeof phrase === 'string') { 374 | result = transformPhrase( 375 | phrase, 376 | opts, 377 | this.currentLocale, 378 | this.tokenRegex, 379 | this.pluralRules, 380 | this.replaceImplementation, 381 | ) 382 | } 383 | 384 | if (result && this.errorOnMissing) { 385 | const matches = result.match(/%{([^}]+)}/g) 386 | if (matches) { 387 | matches.forEach((match: string) => { 388 | // eslint-disable-next-line no-console 389 | console.info(new Error(`translation '${key}' has unused variable key '${match.replace(/%{|}/g, '')}'`).stack) 390 | }) 391 | } 392 | } 393 | 394 | return result as unknown as IfAnyOrNever 395 | } 396 | 397 | has(key: string) { 398 | return this.phrases[key] != null 399 | } 400 | 401 | static transformPhrase(phrase?: any, substitutions?: number | InterpolationOptions, locale?: string) { 402 | return transformPhrase(phrase, substitutions, locale) 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | type IsAny = unknown extends T 2 | ? [keyof T] extends [never] 3 | ? false 4 | : true 5 | : false 6 | 7 | type PathImpl = Key extends string 8 | ? IsAny extends true 9 | ? never 10 | : T[Key] extends Record 11 | ? 12 | | `${Key}.${PathImpl> & 13 | string}` 14 | | `${Key}.${Exclude & string}` 15 | : never 16 | : never 17 | 18 | type PathImpl2 = PathImpl | keyof T 19 | 20 | export type Path = keyof T extends string 21 | ? PathImpl2 extends infer P 22 | ? P extends string | keyof T 23 | ? P 24 | : keyof T 25 | : keyof T 26 | : never 27 | 28 | export type PathValue< 29 | T, 30 | P extends Path, 31 | > = P extends `${infer Key}.${infer Rest}` 32 | ? Key extends keyof T 33 | ? Rest extends Path 34 | ? PathValue 35 | : never 36 | : never 37 | : P extends keyof T 38 | ? T[P] 39 | : never 40 | 41 | export type IfAnyOrNever = 0 extends 1 & T 42 | ? Y 43 | : [T] extends [never] 44 | ? Y 45 | : N 46 | -------------------------------------------------------------------------------- /src/unplugin/core/generate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mkdirSync, 3 | readFileSync, 4 | writeFileSync, 5 | } from 'node:fs' 6 | import { dirname } from 'node:path' 7 | 8 | import type { Options } from '../types' 9 | import { 10 | annotateSourceCode, 11 | createTypesFile, 12 | } from './jsonToTS' 13 | 14 | export async function generateTS(options: Options) { 15 | try { 16 | if (options.exportFilePath) { 17 | try { 18 | const lang = readFileSync(`${options.localesFolder}/${options.selectLanguage}.json`, 'utf8') 19 | const rawContent = await createTypesFile(JSON.parse(lang)) 20 | 21 | if (!rawContent) { 22 | console.warn('No content generated') 23 | return 24 | } 25 | const outputFile = annotateSourceCode(rawContent, options.header) 26 | 27 | mkdirSync(dirname(options!.exportFilePath), { 28 | recursive: true, 29 | }) 30 | let currentFileContent = null 31 | try { 32 | currentFileContent = readFileSync( 33 | options!.exportFilePath, 34 | 'utf8', 35 | ) 36 | } 37 | catch (err) { 38 | console.error(err) 39 | } 40 | if (currentFileContent !== outputFile) { 41 | console.warn('Changes detected in language files', 'SUCCESS') 42 | writeFileSync(options!.exportFilePath, outputFile, { 43 | encoding: 'utf8', 44 | flag: 'w', 45 | mode: 0o666, 46 | }) 47 | console.warn(`Types generated language in: ${options!.exportFilePath}`, 'SUCCESS') 48 | } 49 | else { 50 | console.warn('No changes language files', 'SUCCESS') 51 | } 52 | } 53 | catch (err) { 54 | console.warn(err) 55 | } 56 | } 57 | } 58 | catch (error) { 59 | console.warn(error) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/unplugin/core/jsonToTS.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript' 2 | 3 | export async function convertObjectToTypeDefinition(object: any): Promise { 4 | const typeElements: ts.TypeElement[] = [] 5 | switch (typeof object) { 6 | case 'object': 7 | await Promise.all( 8 | Object.keys(object).map(async (key) => { 9 | if (typeof object[key] === 'string') { 10 | const stringValue = object[key] 11 | // Check if the string contains '%{fooxx}' or '%{bbb}' pattern 12 | const matches = stringValue.match(/%{([^}]+)}/g) 13 | if (matches) { 14 | const variables = matches.map((match: string) => 15 | match.substring(2, match.length - 1), // Remove '%{' and '}' 16 | ) 17 | typeElements.push( 18 | ts.factory.createPropertySignature( 19 | undefined, 20 | ts.factory.createStringLiteral(key), 21 | undefined, 22 | ts.factory.createTypeLiteralNode([ 23 | ts.factory.createPropertySignature( 24 | undefined, 25 | ts.factory.createStringLiteral('variables'), 26 | undefined, 27 | ts.factory.createTypeLiteralNode( 28 | variables.map((variable: string) => 29 | ts.factory.createPropertySignature( 30 | undefined, 31 | ts.factory.createStringLiteral(variable), 32 | undefined, 33 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 34 | ), 35 | ), 36 | ), 37 | ), 38 | ]), 39 | ), 40 | ) 41 | } 42 | else { 43 | typeElements.push( 44 | ts.factory.createPropertySignature( 45 | undefined, 46 | ts.factory.createStringLiteral(key), 47 | undefined, 48 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 49 | ), 50 | ) 51 | } 52 | } 53 | else if (typeof object[key] === 'object') { 54 | const innerTypeElements = await convertObjectToTypeDefinition(object[key]) 55 | typeElements.push( 56 | ts.factory.createPropertySignature( 57 | undefined, 58 | ts.factory.createStringLiteral(key), 59 | undefined, 60 | ts.factory.createTypeLiteralNode(innerTypeElements), 61 | ), 62 | ) 63 | } 64 | }), 65 | ) 66 | return typeElements 67 | } 68 | return [] 69 | } 70 | 71 | export async function createTypesFile(object: any) { 72 | const sourceFile = ts.createSourceFile( 73 | 'placeholder.ts', 74 | '', 75 | ts.ScriptTarget.ESNext, 76 | true, 77 | ts.ScriptKind.TS, 78 | ) 79 | 80 | const i18nTranslationsType = ts.factory.createTypeAliasDeclaration( 81 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 82 | ts.factory.createIdentifier('I18nTranslations'), 83 | undefined, 84 | ts.factory.createTypeLiteralNode( 85 | await convertObjectToTypeDefinition(object), 86 | ), 87 | ) 88 | 89 | const nodes = ts.factory.createNodeArray([ 90 | ts.factory.createImportDeclaration( 91 | undefined, 92 | ts.factory.createImportClause( 93 | false, 94 | undefined, 95 | ts.factory.createNamedImports([ 96 | ts.factory.createImportSpecifier( 97 | false, 98 | undefined, 99 | ts.factory.createIdentifier('Path'), 100 | ), 101 | ]), 102 | ), 103 | ts.factory.createStringLiteral('@productdevbook/ts-i18n'), 104 | undefined, 105 | ), 106 | i18nTranslationsType, 107 | ts.factory.createTypeAliasDeclaration( 108 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 109 | ts.factory.createIdentifier('I18nPath'), 110 | undefined, 111 | ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('Path'), [ 112 | ts.factory.createTypeReferenceNode( 113 | ts.factory.createIdentifier('I18nTranslations'), 114 | undefined, 115 | ), 116 | ]), 117 | ), 118 | ]) 119 | 120 | const printer = ts.createPrinter() 121 | return printer.printList(ts.ListFormat.MultiLine, nodes, sourceFile) 122 | } 123 | 124 | export function annotateSourceCode(code: string, header?: string) { 125 | const eslintDisable = `/* eslint-disable eslint-comments/no-unlimited-disable */ 126 | /* eslint-disable */` 127 | const prettierDisable = `/* prettier-ignore */` 128 | const tsIgnore = `// @ts-ignore` 129 | if (header) { 130 | return `/* DO NOT EDIT, file generated by @productdevbook/ts-i18n */ 131 | ${header.trim()} 132 | 133 | ${code}` 134 | } 135 | else { 136 | return `/* DO NOT EDIT, file generated by @productdevbook/ts-i18n */ 137 | ${eslintDisable} 138 | ${prettierDisable} 139 | ${tsIgnore} 140 | 141 | ${code}` 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/unplugin/core/unplugin.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { createUnplugin } from 'unplugin' 3 | import type { Options } from '../types' 4 | import { generateTS } from './generate' 5 | 6 | export default createUnplugin((options) => { 7 | return { 8 | name: 'unplugin-starter', 9 | async buildStart() { 10 | options = { 11 | exportFilePath: options?.exportFilePath ? resolve(options.exportFilePath) : resolve('./i18n.d.ts'), 12 | localesFolder: options?.localesFolder ? resolve(options.localesFolder) : resolve('./locales'), 13 | selectLanguage: 'en', 14 | header: options?.header, 15 | } 16 | 17 | await generateTS(options) 18 | }, 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /src/unplugin/esbuild.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from './types' 2 | import unplugin from '.' 3 | 4 | // TODO: some upstream lib failed generate invalid dts, remove the any in the future 5 | export default unplugin.esbuild as (options?: Options) => any 6 | -------------------------------------------------------------------------------- /src/unplugin/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './core/unplugin' 2 | -------------------------------------------------------------------------------- /src/unplugin/nuxt.ts: -------------------------------------------------------------------------------- 1 | import { addVitePlugin, addWebpackPlugin, defineNuxtModule } from '@nuxt/kit' 2 | import vite from './vite' 3 | import webpack from './webpack' 4 | import type { Options } from './types' 5 | import '@nuxt/schema' 6 | 7 | export interface ModuleOptions extends Options { 8 | 9 | } 10 | 11 | export default defineNuxtModule({ 12 | meta: { 13 | name: 'tsI18n', 14 | configKey: 'tsI18n', 15 | }, 16 | setup(options, _nuxt) { 17 | addVitePlugin(() => vite(options)) 18 | addWebpackPlugin(() => webpack(options)) 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/unplugin/rollup.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from './types' 2 | import unplugin from '.' 3 | 4 | // TODO: some upstream lib failed generate invalid dts, remove the any in the future 5 | export default unplugin.rollup as (options?: Options) => any 6 | -------------------------------------------------------------------------------- /src/unplugin/types.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | localesFolder: string 3 | exportFilePath: string 4 | selectLanguage: string 5 | header?: string 6 | } 7 | -------------------------------------------------------------------------------- /src/unplugin/vite.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from './types' 2 | import unplugin from '.' 3 | 4 | // TODO: some upstream lib failed generate invalid dts, remove the any in the future 5 | export default unplugin.vite as (options?: Options) => any 6 | -------------------------------------------------------------------------------- /src/unplugin/webpack.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from './types' 2 | import unplugin from '.' 3 | 4 | // TODO: some upstream lib failed generate invalid dts, remove the any in the future 5 | export default unplugin.webpack as (options?: Options) => any 6 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export function forEach(arr: any[], callback: (arg: any) => void) { 2 | for (let i = 0; i < arr.length; i++) 3 | callback(arr[i]) 4 | } 5 | 6 | export const warn = function warn(message: string, type?: 'WARNING' | 'SUCCESS'): void { 7 | if (!type) 8 | type = 'WARNING' 9 | if (typeof console !== 'undefined' && console.warn) 10 | console.warn(`${type}: ${message}`) 11 | } 12 | -------------------------------------------------------------------------------- /test/.cache/i18n.d.ts: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT, file generated by @productdevbook/ts-i18n */ 2 | /* eslint-disable */ 3 | /* prettier-ignore */ 4 | // @ts-ignore 5 | 6 | import { Path } from "nestjs-i18n"; 7 | export type I18nTranslations = { 8 | "hello": string; 9 | "hi_name_welcome_to_place": { 10 | "variables": { 11 | "name": string; 12 | "place": string; 13 | }; 14 | }; 15 | "name_your_name_is_name": { 16 | "variables": { 17 | "name": string; 18 | "name": string; 19 | }; 20 | }; 21 | "empty_string": string; 22 | }; 23 | export type I18nPath = Path; 24 | -------------------------------------------------------------------------------- /test/.cache/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "Hello", 3 | "hi_name_welcome_to_place": "Hi, %{name}, welcome to %{place}!", 4 | "name_your_name_is_name": "'%{name}, your name is %{name}!", 5 | "empty_string": "" 6 | } -------------------------------------------------------------------------------- /test/.cache/locales/tr.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "Merhaba", 3 | "hi_name_welcome_to_place": "Merhaba %{name}, %{place}! hoşgeldin!", 4 | "name_your_name_is_name": "'%{name}, senin adın %{name}!", 5 | "empty_string": "" 6 | } -------------------------------------------------------------------------------- /test/customPluralRules.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { Polyglot } from '@productdevbook/ts-i18n' 3 | 4 | describe('custom pluralRules', () => { 5 | const customPluralRules = { 6 | pluralTypes: { 7 | germanLike(n: number) { 8 | // is 1 9 | if (n === 1) 10 | return 0 11 | 12 | // everything else 13 | return 1 14 | }, 15 | frenchLike(n: number) { 16 | // is 0 or 1 17 | if (n <= 1) 18 | return 0 19 | 20 | // everything else 21 | return 1 22 | }, 23 | }, 24 | pluralTypeToLanguages: { 25 | germanLike: ['x1'], 26 | frenchLike: ['x2'], 27 | }, 28 | } 29 | 30 | const testPhrases = { 31 | test_phrase: '%{smart_count} form zero |||| %{smart_count} form one', 32 | } 33 | 34 | it('pluralizes in x1', () => { 35 | const polyglot = new Polyglot({ 36 | phrases: testPhrases, 37 | locale: 'x1', 38 | pluralRules: customPluralRules, 39 | }) 40 | 41 | expect(polyglot.t('test_phrase', 0)).to.equal('0 form one') 42 | expect(polyglot.t('test_phrase', 1)).to.equal('1 form zero') 43 | expect(polyglot.t('test_phrase', 2)).to.equal('2 form one') 44 | }) 45 | 46 | it('pluralizes in x2', () => { 47 | const polyglot = new Polyglot({ 48 | phrases: testPhrases, 49 | locale: 'x2', 50 | pluralRules: customPluralRules, 51 | }) 52 | 53 | expect(polyglot.t('test_phrase', 0)).to.equal('0 form zero') 54 | expect(polyglot.t('test_phrase', 1)).to.equal('1 form zero') 55 | expect(polyglot.t('test_phrase', 2)).to.equal('2 form one') 56 | }) 57 | 58 | it('memoizes plural type language correctly and selects the correct locale after several calls', () => { 59 | const polyglot = new Polyglot({ 60 | phrases: { 61 | test_phrase: '%{smart_count} Name |||| %{smart_count} Names', 62 | }, 63 | locale: 'x1', 64 | pluralRules: customPluralRules, 65 | }) 66 | 67 | expect(polyglot.t('test_phrase', 0)).to.equal('0 Names') 68 | expect(polyglot.t('test_phrase', 0)).to.equal('0 Names') 69 | expect(polyglot.t('test_phrase', 1)).to.equal('1 Name') 70 | expect(polyglot.t('test_phrase', 1)).to.equal('1 Name') 71 | expect(polyglot.t('test_phrase', 2)).to.equal('2 Names') 72 | expect(polyglot.t('test_phrase', 2)).to.equal('2 Names') 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /test/general.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from 'vitest' 2 | import { Polyglot } from '@productdevbook/ts-i18n' 3 | 4 | describe('pluralize', () => { 5 | const phrases = { 6 | count_name: '%{smart_count} Name |||| %{smart_count} Names', 7 | } 8 | 9 | let polyglot: Polyglot 10 | beforeEach(() => { 11 | polyglot = new Polyglot({ phrases, locale: 'en' }) 12 | }) 13 | 14 | it('supports pluralization with an integer', () => { 15 | expect(polyglot.t('count_name', { smart_count: 0 })).to.equal('0 Names') 16 | expect(polyglot.t('count_name', { smart_count: 1 })).to.equal('1 Name') 17 | expect(polyglot.t('count_name', { smart_count: 2 })).to.equal('2 Names') 18 | expect(polyglot.t('count_name', { smart_count: 3 })).to.equal('3 Names') 19 | }) 20 | 21 | it('accepts a number as a shortcut to pluralize a word', () => { 22 | expect(polyglot.t('count_name', 0)).to.equal('0 Names') 23 | expect(polyglot.t('count_name', 1)).to.equal('1 Name') 24 | expect(polyglot.t('count_name', 2)).to.equal('2 Names') 25 | expect(polyglot.t('count_name', 3)).to.equal('3 Names') 26 | }) 27 | 28 | it('ignores a region subtag when choosing a pluralization rule', () => { 29 | const instance = new Polyglot({ phrases, locale: 'fr-FR' }) 30 | // French rule: "0" is singular 31 | expect(instance.t('count_name', 0)).to.equal('0 Name') 32 | }) 33 | }) 34 | 35 | describe('locale', () => { 36 | let polyglot: Polyglot 37 | beforeEach(() => { 38 | polyglot = new Polyglot({ 39 | locale: 'en', 40 | }) 41 | }) 42 | 43 | it('defaults to "en"', () => { 44 | expect(polyglot.locale()).to.equal('en') 45 | }) 46 | 47 | it('gets and sets locale', () => { 48 | polyglot.locale('es') 49 | expect(polyglot.locale()).to.equal('es') 50 | 51 | polyglot.locale('fr') 52 | expect(polyglot.locale()).to.equal('fr') 53 | }) 54 | }) 55 | 56 | describe('extend', () => { 57 | let polyglot: Polyglot 58 | beforeEach(() => { 59 | polyglot = new Polyglot({ 60 | locale: 'en', 61 | }) 62 | }) 63 | 64 | it('handles null gracefully', () => { 65 | expect(() => { 66 | polyglot.extend(null) 67 | }).to.not.throw() 68 | }) 69 | 70 | it('handles undefined gracefully', () => { 71 | expect(() => { 72 | polyglot.extend(undefined) 73 | }).to.not.throw() 74 | }) 75 | 76 | it('supports multiple extends, overriding old keys', () => { 77 | polyglot.extend({ aKey: 'First time' }) 78 | polyglot.extend({ aKey: 'Second time' }) 79 | expect(polyglot.t('aKey')).to.equal('Second time') 80 | }) 81 | 82 | it('does not forget old keys', () => { 83 | polyglot.extend({ firstKey: 'Numba one', secondKey: 'Numba two' }) 84 | polyglot.extend({ secondKey: 'Numero dos' }) 85 | expect(polyglot.t('firstKey')).to.equal('Numba one') 86 | }) 87 | 88 | it('supports optional `prefix` argument', () => { 89 | polyglot.extend({ click: 'Click', hover: 'Hover' }, 'sidebar') 90 | expect(polyglot.phrases['sidebar.click']).to.equal('Click') 91 | expect(polyglot.phrases['sidebar.hover']).to.equal('Hover') 92 | expect(polyglot.phrases).not.to.have.property('click') 93 | }) 94 | 95 | it('supports nested object', () => { 96 | polyglot.extend({ 97 | sidebar: { 98 | click: 'Click', 99 | hover: 'Hover', 100 | }, 101 | nav: { 102 | header: { 103 | log_in: 'Log In', 104 | }, 105 | }, 106 | }) 107 | expect(polyglot.phrases['sidebar.click']).to.equal('Click') 108 | expect(polyglot.phrases['sidebar.hover']).to.equal('Hover') 109 | expect(polyglot.phrases['nav.header.log_in']).to.equal('Log In') 110 | expect(polyglot.phrases).not.to.have.property('click') 111 | expect(polyglot.phrases).not.to.have.property('header.log_in') 112 | expect(polyglot.phrases).not.to.have.property('log_in') 113 | }) 114 | }) 115 | 116 | describe('clear', () => { 117 | let polyglot: Polyglot 118 | beforeEach(() => { 119 | polyglot = new Polyglot({ 120 | locale: 'en', 121 | }) 122 | }) 123 | 124 | it('wipes out old phrases', () => { 125 | polyglot.extend({ hiFriend: 'Hi, Friend.' }) 126 | polyglot.clear() 127 | expect(polyglot.t('hiFriend')).to.equal('hiFriend') 128 | }) 129 | }) 130 | 131 | describe('replace', () => { 132 | let polyglot: Polyglot 133 | beforeEach(() => { 134 | polyglot = new Polyglot({ 135 | locale: 'en', 136 | }) 137 | }) 138 | 139 | it('wipes out old phrases and replace with new phrases', () => { 140 | polyglot.extend({ hiFriend: 'Hi, Friend.', byeFriend: 'Bye, Friend.' }) 141 | polyglot.replace({ hiFriend: 'Hi, Friend.' }) 142 | expect(polyglot.t('hiFriend')).to.equal('Hi, Friend.') 143 | expect(polyglot.t('byeFriend')).to.equal('byeFriend') 144 | }) 145 | }) 146 | 147 | describe('unset', () => { 148 | let polyglot: Polyglot 149 | beforeEach(() => { 150 | polyglot = new Polyglot({ 151 | locale: 'en', 152 | }) 153 | }) 154 | 155 | it('handles null gracefully', () => { 156 | expect(() => { 157 | polyglot.unset(null) 158 | }).to.not.throw() 159 | }) 160 | 161 | it('handles undefined gracefully', () => { 162 | expect(() => { 163 | polyglot.unset(undefined) 164 | }).to.not.throw() 165 | }) 166 | 167 | it('unsets a key based on a string', () => { 168 | polyglot.extend({ test_key: 'test_value' }) 169 | expect(polyglot.has('test_key')).to.equal(true) 170 | 171 | polyglot.unset('test_key') 172 | expect(polyglot.has('test_key')).to.equal(false) 173 | }) 174 | 175 | it('unsets a key based on an object hash', () => { 176 | polyglot.extend({ test_key: 'test_value', foo: 'bar' }) 177 | expect(polyglot.has('test_key')).to.equal(true) 178 | expect(polyglot.has('foo')).to.equal(true) 179 | 180 | polyglot.unset({ test_key: 'test_value', foo: 'bar' }) 181 | expect(polyglot.has('test_key')).to.equal(false) 182 | expect(polyglot.has('foo')).to.equal(false) 183 | }) 184 | 185 | it('unsets nested objects using recursive prefix call', () => { 186 | polyglot.extend({ foo: { bar: 'foobar' } }) 187 | expect(polyglot.has('foo.bar')).to.equal(true) 188 | 189 | polyglot.unset({ foo: { bar: 'foobar' } }) 190 | expect(polyglot.has('foo.bar')).to.equal(false) 191 | }) 192 | }) 193 | -------------------------------------------------------------------------------- /test/locale-specific.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { Polyglot, forEach } from '@productdevbook/ts-i18n' 3 | 4 | describe('locale-specific pluralization rules', () => { 5 | it('pluralizes in Arabic', () => { 6 | // English would be: "1 vote" / "%{smart_count} votes" 7 | const whatSomeoneTranslated = [ 8 | 'ولا صوت', 9 | 'صوت واحد', 10 | 'صوتان', 11 | '%{smart_count} أصوات', 12 | '%{smart_count} صوت', 13 | '%{smart_count} صوت', 14 | ] 15 | const phrases = { 16 | n_votes: whatSomeoneTranslated.join(' |||| '), 17 | } 18 | 19 | const polyglot = new Polyglot({ phrases, locale: 'ar' }) 20 | 21 | expect(polyglot.t('n_votes', 0)).to.equal('ولا صوت') 22 | expect(polyglot.t('n_votes', 1)).to.equal('صوت واحد') 23 | expect(polyglot.t('n_votes', 2)).to.equal('صوتان') 24 | expect(polyglot.t('n_votes', 3)).to.equal('3 أصوات') 25 | expect(polyglot.t('n_votes', 11)).to.equal('11 صوت') 26 | expect(polyglot.t('n_votes', 102)).to.equal('102 صوت') 27 | }) 28 | 29 | it('interpolates properly in Arabic', () => { 30 | const phrases = { 31 | hello: 'الرمز ${code} غير صحيح', // eslint-disable-line no-template-curly-in-string 32 | } 33 | 34 | const polyglot = new Polyglot({ 35 | phrases, 36 | locale: 'ar', 37 | interpolation: { prefix: '${', suffix: '}' }, 38 | }) 39 | 40 | expect(polyglot.t('hello', { code: 'De30Niro' })).to.equal('الرمز De30Niro غير صحيح') 41 | 42 | // note how the "30" in the next line shows up in the wrong place: 43 | expect(polyglot.t('hello', { code: '30DeNiro' })).to.equal('الرمز 30DeNiro غير صحيح') 44 | // but with a directional marker character, it shows up in the right place: 45 | expect(polyglot.t('hello', { code: '\u200E30DeNiroMarker' })).to.equal('الرمز \u200E30DeNiroMarker غير صحيح') 46 | // see https://github.com/airbnb/polyglot.js/issues/167 / https://stackoverflow.com/a/34903965 for why it's impractical to handle in polyglot 47 | }) 48 | 49 | it('pluralizes in Russian', () => { 50 | // English would be: "1 vote" / "%{smart_count} votes" 51 | const whatSomeoneTranslated = [ 52 | '%{smart_count} машина', 53 | '%{smart_count} машины', 54 | '%{smart_count} машин', 55 | ] 56 | const phrases = { 57 | n_votes: whatSomeoneTranslated.join(' |||| '), 58 | } 59 | 60 | const polyglotLanguageCode = new Polyglot({ phrases, locale: 'ru' }) 61 | 62 | expect(polyglotLanguageCode.t('n_votes', 1)).to.equal('1 машина') 63 | expect(polyglotLanguageCode.t('n_votes', 11)).to.equal('11 машин') 64 | expect(polyglotLanguageCode.t('n_votes', 101)).to.equal('101 машина') 65 | expect(polyglotLanguageCode.t('n_votes', 112)).to.equal('112 машин') 66 | expect(polyglotLanguageCode.t('n_votes', 932)).to.equal('932 машины') 67 | expect(polyglotLanguageCode.t('n_votes', 324)).to.equal('324 машины') 68 | expect(polyglotLanguageCode.t('n_votes', 12)).to.equal('12 машин') 69 | expect(polyglotLanguageCode.t('n_votes', 13)).to.equal('13 машин') 70 | expect(polyglotLanguageCode.t('n_votes', 14)).to.equal('14 машин') 71 | expect(polyglotLanguageCode.t('n_votes', 15)).to.equal('15 машин') 72 | 73 | const polyglotLocaleId = new Polyglot({ phrases, locale: 'ru-RU' }) 74 | 75 | expect(polyglotLocaleId.t('n_votes', 1)).to.equal('1 машина') 76 | expect(polyglotLocaleId.t('n_votes', 11)).to.equal('11 машин') 77 | expect(polyglotLocaleId.t('n_votes', 101)).to.equal('101 машина') 78 | expect(polyglotLocaleId.t('n_votes', 112)).to.equal('112 машин') 79 | expect(polyglotLocaleId.t('n_votes', 932)).to.equal('932 машины') 80 | expect(polyglotLocaleId.t('n_votes', 324)).to.equal('324 машины') 81 | expect(polyglotLocaleId.t('n_votes', 12)).to.equal('12 машин') 82 | expect(polyglotLocaleId.t('n_votes', 13)).to.equal('13 машин') 83 | expect(polyglotLocaleId.t('n_votes', 14)).to.equal('14 машин') 84 | expect(polyglotLocaleId.t('n_votes', 15)).to.equal('15 машин') 85 | }) 86 | 87 | it('pluralizes in Croatian (guest) Test', () => { 88 | // English would be: "1 vote" / "%{smart_count} votes" 89 | const whatSomeoneTranslated = [ 90 | '%{smart_count} gost', 91 | '%{smart_count} gosta', 92 | '%{smart_count} gostiju', 93 | ] 94 | const phrases = { 95 | n_guests: whatSomeoneTranslated.join(' |||| '), 96 | } 97 | 98 | const polyglotLocale = new Polyglot({ phrases, locale: 'hr-HR' }) 99 | 100 | expect(polyglotLocale.t('n_guests', 1)).to.equal('1 gost') 101 | expect(polyglotLocale.t('n_guests', 11)).to.equal('11 gostiju') 102 | expect(polyglotLocale.t('n_guests', 21)).to.equal('21 gost') 103 | 104 | expect(polyglotLocale.t('n_guests', 2)).to.equal('2 gosta') 105 | expect(polyglotLocale.t('n_guests', 3)).to.equal('3 gosta') 106 | expect(polyglotLocale.t('n_guests', 4)).to.equal('4 gosta') 107 | 108 | expect(polyglotLocale.t('n_guests', 12)).to.equal('12 gostiju') 109 | expect(polyglotLocale.t('n_guests', 13)).to.equal('13 gostiju') 110 | expect(polyglotLocale.t('n_guests', 14)).to.equal('14 gostiju') 111 | expect(polyglotLocale.t('n_guests', 112)).to.equal('112 gostiju') 112 | expect(polyglotLocale.t('n_guests', 113)).to.equal('113 gostiju') 113 | expect(polyglotLocale.t('n_guests', 114)).to.equal('114 gostiju') 114 | }) 115 | 116 | it('pluralizes in Croatian (vote) Test', () => { 117 | // English would be: "1 vote" / "%{smart_count} votes" 118 | const whatSomeoneTranslated = [ 119 | '%{smart_count} glas', 120 | '%{smart_count} glasa', 121 | '%{smart_count} glasova', 122 | ] 123 | const phrases = { 124 | n_votes: whatSomeoneTranslated.join(' |||| '), 125 | } 126 | 127 | const polyglotLocale = new Polyglot({ phrases, locale: 'hr-HR' }) 128 | 129 | forEach([1, 21, 31, 101], (c) => { 130 | expect(polyglotLocale.t('n_votes', c)).to.equal(`${c} glas`) 131 | }) 132 | forEach([2, 3, 4, 22, 23, 24, 32, 33, 34], (c) => { 133 | expect(polyglotLocale.t('n_votes', c)).to.equal(`${c} glasa`) 134 | }) 135 | forEach([0, 5, 6, 11, 12, 13, 14, 15, 16, 17, 25, 26, 35, 36, 112, 113, 114], (c) => { 136 | expect(polyglotLocale.t('n_votes', c)).to.equal(`${c} glasova`) 137 | }) 138 | 139 | const polyglotLanguageCode = new Polyglot({ phrases, locale: 'hr' }) 140 | 141 | forEach([1, 21, 31, 101], (c) => { 142 | expect(polyglotLanguageCode.t('n_votes', c)).to.equal(`${c} glas`) 143 | }) 144 | forEach([2, 3, 4, 22, 23, 24, 32, 33, 34], (c) => { 145 | expect(polyglotLanguageCode.t('n_votes', c)).to.equal(`${c} glasa`) 146 | }) 147 | forEach([0, 5, 6, 11, 12, 13, 14, 15, 16, 17, 25, 26, 35, 36, 112, 113, 114], (c) => { 148 | expect(polyglotLanguageCode.t('n_votes', c)).to.equal(`${c} glasova`) 149 | }) 150 | }) 151 | 152 | it('pluralizes in Serbian (Latin & Cyrillic)', () => { 153 | // English would be: "1 vote" / "%{smart_count} votes" 154 | const whatSomeoneTranslated = [ 155 | '%{smart_count} miš', 156 | '%{smart_count} miša', 157 | '%{smart_count} miševa', 158 | ] 159 | const phrases = { 160 | n_votes: whatSomeoneTranslated.join(' |||| '), 161 | } 162 | 163 | const polyglotLatin = new Polyglot({ phrases, locale: 'srl-RS' }) 164 | 165 | expect(polyglotLatin.t('n_votes', 1)).to.equal('1 miš') 166 | expect(polyglotLatin.t('n_votes', 11)).to.equal('11 miševa') 167 | expect(polyglotLatin.t('n_votes', 101)).to.equal('101 miš') 168 | expect(polyglotLatin.t('n_votes', 932)).to.equal('932 miša') 169 | expect(polyglotLatin.t('n_votes', 324)).to.equal('324 miša') 170 | expect(polyglotLatin.t('n_votes', 12)).to.equal('12 miševa') 171 | expect(polyglotLatin.t('n_votes', 13)).to.equal('13 miševa') 172 | expect(polyglotLatin.t('n_votes', 14)).to.equal('14 miševa') 173 | expect(polyglotLatin.t('n_votes', 15)).to.equal('15 miševa') 174 | expect(polyglotLatin.t('n_votes', 0)).to.equal('0 miševa') 175 | 176 | const polyglotCyrillic = new Polyglot({ phrases, locale: 'sr-RS' }) 177 | 178 | expect(polyglotCyrillic.t('n_votes', 1)).to.equal('1 miš') 179 | expect(polyglotCyrillic.t('n_votes', 11)).to.equal('11 miševa') 180 | expect(polyglotCyrillic.t('n_votes', 101)).to.equal('101 miš') 181 | expect(polyglotCyrillic.t('n_votes', 932)).to.equal('932 miša') 182 | expect(polyglotCyrillic.t('n_votes', 324)).to.equal('324 miša') 183 | expect(polyglotCyrillic.t('n_votes', 12)).to.equal('12 miševa') 184 | expect(polyglotCyrillic.t('n_votes', 13)).to.equal('13 miševa') 185 | expect(polyglotCyrillic.t('n_votes', 14)).to.equal('14 miševa') 186 | expect(polyglotCyrillic.t('n_votes', 15)).to.equal('15 miševa') 187 | expect(polyglotCyrillic.t('n_votes', 0)).to.equal('0 miševa') 188 | }) 189 | 190 | it('pluralizes in Bosnian (Latin & Cyrillic)', () => { 191 | // English would be: "1 vote" / "%{smart_count} votes" 192 | const whatSomeoneTranslated = [ 193 | '%{smart_count} članak', 194 | '%{smart_count} članka', 195 | '%{smart_count} članaka', 196 | ] 197 | const phrases = { 198 | n_votes: whatSomeoneTranslated.join(' |||| '), 199 | } 200 | 201 | const polyglotLatin = new Polyglot({ phrases, locale: 'bs-Latn-BA' }) 202 | 203 | expect(polyglotLatin.t('n_votes', 1)).to.equal('1 članak') 204 | expect(polyglotLatin.t('n_votes', 11)).to.equal('11 članaka') 205 | expect(polyglotLatin.t('n_votes', 101)).to.equal('101 članak') 206 | expect(polyglotLatin.t('n_votes', 932)).to.equal('932 članka') 207 | expect(polyglotLatin.t('n_votes', 324)).to.equal('324 članka') 208 | expect(polyglotLatin.t('n_votes', 12)).to.equal('12 članaka') 209 | expect(polyglotLatin.t('n_votes', 13)).to.equal('13 članaka') 210 | expect(polyglotLatin.t('n_votes', 14)).to.equal('14 članaka') 211 | expect(polyglotLatin.t('n_votes', 15)).to.equal('15 članaka') 212 | expect(polyglotLatin.t('n_votes', 112)).to.equal('112 članaka') 213 | expect(polyglotLatin.t('n_votes', 113)).to.equal('113 članaka') 214 | expect(polyglotLatin.t('n_votes', 114)).to.equal('114 članaka') 215 | expect(polyglotLatin.t('n_votes', 115)).to.equal('115 članaka') 216 | expect(polyglotLatin.t('n_votes', 0)).to.equal('0 članaka') 217 | 218 | const polyglotCyrillic = new Polyglot({ phrases, locale: 'bs-Cyrl-BA' }) 219 | 220 | expect(polyglotCyrillic.t('n_votes', 1)).to.equal('1 članak') 221 | expect(polyglotCyrillic.t('n_votes', 11)).to.equal('11 članaka') 222 | expect(polyglotCyrillic.t('n_votes', 101)).to.equal('101 članak') 223 | expect(polyglotCyrillic.t('n_votes', 932)).to.equal('932 članka') 224 | expect(polyglotCyrillic.t('n_votes', 324)).to.equal('324 članka') 225 | expect(polyglotCyrillic.t('n_votes', 12)).to.equal('12 članaka') 226 | expect(polyglotCyrillic.t('n_votes', 13)).to.equal('13 članaka') 227 | expect(polyglotCyrillic.t('n_votes', 14)).to.equal('14 članaka') 228 | expect(polyglotCyrillic.t('n_votes', 15)).to.equal('15 članaka') 229 | expect(polyglotCyrillic.t('n_votes', 112)).to.equal('112 članaka') 230 | expect(polyglotCyrillic.t('n_votes', 113)).to.equal('113 članaka') 231 | expect(polyglotCyrillic.t('n_votes', 114)).to.equal('114 članaka') 232 | expect(polyglotCyrillic.t('n_votes', 115)).to.equal('115 članaka') 233 | expect(polyglotCyrillic.t('n_votes', 0)).to.equal('0 članaka') 234 | }) 235 | 236 | it('pluralizes in Czech', () => { 237 | // English would be: "1 vote" / "%{smart_count} votes" 238 | const whatSomeoneTranslated = [ 239 | '%{smart_count} komentář', 240 | '%{smart_count} komentáře', 241 | '%{smart_count} komentářů', 242 | ] 243 | const phrases = { 244 | n_votes: whatSomeoneTranslated.join(' |||| '), 245 | } 246 | 247 | const polyglot = new Polyglot({ phrases, locale: 'cs-CZ' }) 248 | 249 | expect(polyglot.t('n_votes', 1)).to.equal('1 komentář') 250 | expect(polyglot.t('n_votes', 2)).to.equal('2 komentáře') 251 | expect(polyglot.t('n_votes', 3)).to.equal('3 komentáře') 252 | expect(polyglot.t('n_votes', 4)).to.equal('4 komentáře') 253 | expect(polyglot.t('n_votes', 0)).to.equal('0 komentářů') 254 | expect(polyglot.t('n_votes', 11)).to.equal('11 komentářů') 255 | expect(polyglot.t('n_votes', 12)).to.equal('12 komentářů') 256 | expect(polyglot.t('n_votes', 16)).to.equal('16 komentářů') 257 | }) 258 | 259 | it('pluralizes in Slovenian', () => { 260 | // English would be: "1 vote" / "%{smart_count} votes" 261 | const whatSomeoneTranslated = [ 262 | '%{smart_count} komentar', 263 | '%{smart_count} komentarja', 264 | '%{smart_count} komentarji', 265 | '%{smart_count} komentarjev', 266 | ] 267 | const phrases = { 268 | n_votes: whatSomeoneTranslated.join(' |||| '), 269 | } 270 | 271 | const polyglot = new Polyglot({ phrases, locale: 'sl-SL' }) 272 | 273 | forEach([1, 12301, 101, 1001, 201, 301], (c) => { 274 | expect(polyglot.t('n_votes', c)).to.equal(`${c} komentar`) 275 | }) 276 | 277 | forEach([2, 102, 202, 302], (c) => { 278 | expect(polyglot.t('n_votes', c)).to.equal(`${c} komentarja`) 279 | }) 280 | 281 | forEach([0, 11, 12, 13, 14, 52, 53], (c) => { 282 | expect(polyglot.t('n_votes', c)).to.equal(`${c} komentarjev`) 283 | }) 284 | }) 285 | 286 | it('pluralizes in Turkish', () => { 287 | const whatSomeoneTranslated = [ 288 | 'Sepetinizde %{smart_count} X var. Bunu almak istiyor musunuz?', 289 | 'Sepetinizde %{smart_count} X var. Bunları almak istiyor musunuz?', 290 | ] 291 | const phrases = { 292 | n_x_cart: whatSomeoneTranslated.join(' |||| '), 293 | } 294 | 295 | const polyglot = new Polyglot({ phrases, locale: 'tr' }) 296 | 297 | expect(polyglot.t('n_x_cart', 1)).to.equal('Sepetinizde 1 X var. Bunu almak istiyor musunuz?') 298 | expect(polyglot.t('n_x_cart', 2)).to.equal('Sepetinizde 2 X var. Bunları almak istiyor musunuz?') 299 | }) 300 | 301 | it('pluralizes in Lithuanian', () => { 302 | const whatSomeoneTranslated = [ 303 | '%{smart_count} balsas', 304 | '%{smart_count} balsai', 305 | '%{smart_count} balsų', 306 | ] 307 | const phrases = { 308 | n_votes: whatSomeoneTranslated.join(' |||| '), 309 | } 310 | const polyglot = new Polyglot({ phrases, locale: 'lt' }) 311 | 312 | expect(polyglot.t('n_votes', 0)).to.equal('0 balsų') 313 | expect(polyglot.t('n_votes', 1)).to.equal('1 balsas') 314 | expect(polyglot.t('n_votes', 2)).to.equal('2 balsai') 315 | expect(polyglot.t('n_votes', 9)).to.equal('9 balsai') 316 | expect(polyglot.t('n_votes', 10)).to.equal('10 balsų') 317 | expect(polyglot.t('n_votes', 11)).to.equal('11 balsų') 318 | expect(polyglot.t('n_votes', 12)).to.equal('12 balsų') 319 | expect(polyglot.t('n_votes', 90)).to.equal('90 balsų') 320 | expect(polyglot.t('n_votes', 91)).to.equal('91 balsas') 321 | expect(polyglot.t('n_votes', 92)).to.equal('92 balsai') 322 | expect(polyglot.t('n_votes', 102)).to.equal('102 balsai') 323 | }) 324 | 325 | it('pluralizes in Romanian', () => { 326 | const whatSomeoneTranslated = [ 327 | '%{smart_count} zi', 328 | '%{smart_count} zile', 329 | '%{smart_count} de zile', 330 | ] 331 | const phrases = { 332 | n_days: whatSomeoneTranslated.join(' |||| '), 333 | } 334 | const polyglot = new Polyglot({ phrases, locale: 'ro' }) 335 | 336 | expect(polyglot.t('n_days', 0)).to.equal('0 zile') 337 | expect(polyglot.t('n_days', 1)).to.equal('1 zi') 338 | expect(polyglot.t('n_days', 2)).to.equal('2 zile') 339 | expect(polyglot.t('n_days', 10)).to.equal('10 zile') 340 | expect(polyglot.t('n_days', 19)).to.equal('19 zile') 341 | expect(polyglot.t('n_days', 20)).to.equal('20 de zile') 342 | expect(polyglot.t('n_days', 21)).to.equal('21 de zile') 343 | expect(polyglot.t('n_days', 100)).to.equal('100 de zile') 344 | expect(polyglot.t('n_days', 101)).to.equal('101 de zile') 345 | expect(polyglot.t('n_days', 102)).to.equal('102 zile') 346 | expect(polyglot.t('n_days', 119)).to.equal('119 zile') 347 | expect(polyglot.t('n_days', 120)).to.equal('120 de zile') 348 | }) 349 | 350 | it('pluralizes in Macedonian', () => { 351 | const whatSomeoneTranslated = [ 352 | '%{smart_count} ден', 353 | '%{smart_count} дена', 354 | ] 355 | const phrases = { 356 | n_days: whatSomeoneTranslated.join(' |||| '), 357 | } 358 | const polyglot = new Polyglot({ phrases, locale: 'mk' }) 359 | 360 | expect(polyglot.t('n_days', 0)).to.equal('0 дена') 361 | expect(polyglot.t('n_days', 1)).to.equal('1 ден') 362 | expect(polyglot.t('n_days', 2)).to.equal('2 дена') 363 | expect(polyglot.t('n_days', 10)).to.equal('10 дена') 364 | expect(polyglot.t('n_days', 11)).to.equal('11 дена') 365 | expect(polyglot.t('n_days', 21)).to.equal('21 ден') 366 | expect(polyglot.t('n_days', 100)).to.equal('100 дена') 367 | expect(polyglot.t('n_days', 101)).to.equal('101 ден') 368 | expect(polyglot.t('n_days', 111)).to.equal('111 дена') 369 | }) 370 | }) 371 | -------------------------------------------------------------------------------- /test/t.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from 'vitest' 2 | import type { InterpolationTokenOptions } from '@productdevbook/ts-i18n' 3 | import { Polyglot } from '@productdevbook/ts-i18n' 4 | import iterate from 'iterate-iterator' 5 | 6 | // The two tests marked with concurrent will be run in parallel 7 | describe('t', () => { 8 | const phrases = { 9 | hello: 'Hello', 10 | hi_name_welcome_to_place: 'Hi, %{name}, welcome to %{place}!', 11 | name_your_name_is_name: '%{name}, your name is %{name}!', 12 | empty_string: '', 13 | } 14 | 15 | let polyglot: Polyglot 16 | beforeEach(() => { 17 | polyglot = new Polyglot({ phrases, locale: 'en' }) 18 | }) 19 | 20 | it('translates a simple string', () => { 21 | expect(polyglot.t('hello')).to.equal('Hello') 22 | }) 23 | 24 | it('returns the key if translation not found', () => { 25 | expect(polyglot.t('bogus_key')).to.equal('bogus_key') 26 | }) 27 | 28 | it('interpolates', () => { 29 | expect(polyglot.t('hi_name_welcome_to_place', { 30 | name: 'Spike', 31 | place: 'the webz', 32 | })).to.equal('Hi, Spike, welcome to the webz!') 33 | }) 34 | 35 | it('interpolates with missing substitutions', () => { 36 | expect(polyglot.t('hi_name_welcome_to_place', { 37 | place: undefined, 38 | })).to.equal('Hi, %{name}, welcome to %{place}!') 39 | }) 40 | 41 | it('interpolates the same placeholder multiple times', () => { 42 | expect(polyglot.t('name_your_name_is_name', { 43 | name: 'Spike', 44 | })).to.equal('Spike, your name is Spike!') 45 | }) 46 | 47 | it('allows you to supply default values', () => { 48 | expect(polyglot.t('can_i_call_you_name', { 49 | _: 'Can I call you %{name}?', 50 | name: 'Robert', 51 | })).to.equal('Can I call you Robert?') 52 | }) 53 | 54 | it('returns the non-interpolated key if not initialized with allowMissing and translation not found', () => { 55 | expect(polyglot.t('Welcome %{name}', { 56 | name: 'Robert', 57 | })).to.equal('Welcome %{name}') 58 | }) 59 | 60 | it('returns an interpolated key if initialized with allowMissing and translation not found', () => { 61 | const instance = new Polyglot({ locale: 'en', phrases, allowMissing: true }) 62 | expect(instance.t('Welcome %{name}', { 63 | name: 'Robert', 64 | })).to.equal('Welcome Robert') 65 | }) 66 | 67 | describe('custom interpolation syntax', () => { 68 | const createWithInterpolation = (interpolation: InterpolationTokenOptions) => { 69 | return new Polyglot({ locale: 'en', phrases: {}, allowMissing: true, interpolation }) 70 | } 71 | 72 | it('interpolates with the specified custom token syntax', () => { 73 | const instance = createWithInterpolation({ prefix: '{{', suffix: '}}' }) 74 | expect(instance.t('Welcome {{name}}', { 75 | name: 'Robert', 76 | })).to.equal('Welcome Robert') 77 | }) 78 | 79 | it('interpolates if the prefix and suffix are the same', () => { 80 | const instance = createWithInterpolation({ prefix: '|', suffix: '|' }) 81 | expect(instance.t('Welcome |name|, how are you, |name|?', { 82 | name: 'Robert', 83 | })).to.equal('Welcome Robert, how are you, Robert?') 84 | }) 85 | 86 | it('interpolates when using regular expression tokens', () => { 87 | const instance = createWithInterpolation({ prefix: '\\s.*', suffix: '\\d.+' }) 88 | expect(instance.t('Welcome \\s.*name\\d.+', { 89 | name: 'Robert', 90 | })).to.equal('Welcome Robert') 91 | }) 92 | 93 | it('throws an error when either prefix or suffix equals to pluralization delimiter', () => { 94 | expect(() => { 95 | createWithInterpolation({ prefix: '||||', suffix: '}}' }) 96 | }).to.throw(RangeError) 97 | expect(() => { 98 | createWithInterpolation({ prefix: '{{', suffix: '||||' }) 99 | }).to.throw(RangeError) 100 | }) 101 | }) 102 | 103 | it('returns the translation even if it is an empty string', () => { 104 | expect(polyglot.t('empty_string')).to.equal('') 105 | }) 106 | 107 | it('returns the default value even if it is an empty string', () => { 108 | expect(polyglot.t('bogus_key', { _: '' })).to.equal('') 109 | }) 110 | 111 | it('handles dollar signs in the substitution value', () => { 112 | expect(polyglot.t('hi_name_welcome_to_place', { 113 | name: '$abc $0', 114 | place: '$1 $&', 115 | })).to.equal('Hi, $abc $0, welcome to $1 $&!') 116 | }) 117 | 118 | it('supports nested phrase objects', () => { 119 | const nestedPhrases = { 120 | 'nav': { 121 | presentations: 'Presentations', 122 | hi_user: 'Hi, %{user}.', 123 | cta: { 124 | join_now: 'Join now!', 125 | }, 126 | }, 127 | 'header.sign_in': 'Sign In', 128 | } 129 | const instance = new Polyglot({ locale: 'en', phrases: nestedPhrases }) 130 | expect(instance.t('nav.presentations')).to.equal('Presentations') 131 | expect(instance.t('nav.hi_user', { user: 'Raph' })).to.equal('Hi, Raph.') 132 | expect(instance.t('nav.cta.join_now')).to.equal('Join now!') 133 | expect(instance.t('header.sign_in')).to.equal('Sign In') 134 | }) 135 | 136 | it('supports custom replace implementation', () => { 137 | const instance = new Polyglot({ 138 | locale: 'en', 139 | phrases, 140 | replace(interpolationRegex, callback) { 141 | const phrase = this as any as string 142 | let i = 0 143 | const children = [] 144 | 145 | iterate(phrase.matchAll(interpolationRegex), (match: any) => { 146 | if (match.index > i) 147 | children.push(phrase.slice(i, match.index)) 148 | 149 | children.push(callback(match[0], match[1])) 150 | i = match.index + match[0].length 151 | }) 152 | 153 | if (i < phrase.length) 154 | children.push(phrase.slice(i)) 155 | 156 | return { type: 'might_be_react_fragment', children } 157 | }, 158 | }) 159 | 160 | expect(instance.t( 161 | 'hi_name_welcome_to_place', 162 | { 163 | name: { type: 'might_be_react_node', children: ['Rudolf'] }, 164 | place: { type: 'might_be_react_node', children: ['Earth'] }, 165 | }, 166 | )).to.deep.equal({ 167 | children: [ 168 | 'Hi, ', 169 | { 170 | children: [ 171 | 'Rudolf', 172 | ], 173 | type: 'might_be_react_node', 174 | }, 175 | ', welcome to ', 176 | { 177 | children: [ 178 | 'Earth', 179 | ], 180 | type: 'might_be_react_node', 181 | }, 182 | '!', 183 | ], 184 | type: 'might_be_react_fragment', 185 | }) 186 | }) 187 | 188 | describe('onMissingKey', () => { 189 | it('calls the function when a key is missing', () => { 190 | const expectedKey = 'some key' 191 | const expectedOptions = {} 192 | const expectedLocale = 'oz' 193 | const returnValue = {} as any 194 | const onMissingKey = (key: string, options: any, locale: string) => { 195 | expect(key).to.equal(expectedKey) 196 | expect(options).to.equal(expectedOptions) 197 | expect(locale).to.equal(expectedLocale) 198 | return returnValue 199 | } 200 | const instance = new Polyglot({ onMissingKey, locale: expectedLocale }) 201 | const result = instance.t(expectedKey, expectedOptions) 202 | expect(result).to.equal(returnValue) 203 | }) 204 | 205 | it('overrides allowMissing', (done) => { 206 | const missingKey = 'missing key' 207 | const onMissingKey = (key: string) => { 208 | expect(key).to.equal(missingKey) 209 | done 210 | } 211 | const instance = new Polyglot({ locale: 'en', onMissingKey, allowMissing: true }) 212 | instance.t(missingKey) 213 | }) 214 | }) 215 | }) 216 | -------------------------------------------------------------------------------- /test/transformPhrase.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { Polyglot } from '@productdevbook/ts-i18n' 3 | 4 | describe('transformPhrase', () => { 5 | const simple = '%{name} is %{attribute}' 6 | const english = '%{smart_count} Name |||| %{smart_count} Names' 7 | const arabic = [ 8 | 'ولا صوت', 9 | 'صوت واحد', 10 | 'صوتان', 11 | '%{smart_count} أصوات', 12 | '%{smart_count} صوت', 13 | '%{smart_count} صوت', 14 | ].join(' |||| ') 15 | 16 | it('does simple interpolation', () => { 17 | expect(Polyglot.transformPhrase(simple, { name: 'Polyglot', attribute: 'awesome' })).to.equal('Polyglot is awesome') 18 | }) 19 | 20 | it('removes missing keys', () => { 21 | expect(Polyglot.transformPhrase(simple, { name: 'Polyglot' })).to.equal('Polyglot is %{attribute}') 22 | }) 23 | 24 | it('selects the correct plural form based on smart_count', () => { 25 | expect(Polyglot.transformPhrase(english, { smart_count: 0 }, 'en')).to.equal('0 Names') 26 | expect(Polyglot.transformPhrase(english, { smart_count: 1 }, 'en')).to.equal('1 Name') 27 | expect(Polyglot.transformPhrase(english, { smart_count: 2 }, 'en')).to.equal('2 Names') 28 | expect(Polyglot.transformPhrase(english, { smart_count: 3 }, 'en')).to.equal('3 Names') 29 | }) 30 | 31 | it('selects the correct locale', () => { 32 | // French rule: "0" is singular 33 | expect(Polyglot.transformPhrase(english, { smart_count: 0 }, 'fr')).to.equal('0 Name') 34 | expect(Polyglot.transformPhrase(english, { smart_count: 1 }, 'fr')).to.equal('1 Name') 35 | expect(Polyglot.transformPhrase(english, { smart_count: 1.5 }, 'fr')).to.equal('1.5 Name') 36 | // French rule: plural starts at 2 included 37 | expect(Polyglot.transformPhrase(english, { smart_count: 2 }, 'fr')).to.equal('2 Names') 38 | expect(Polyglot.transformPhrase(english, { smart_count: 3 }, 'fr')).to.equal('3 Names') 39 | 40 | // Arabic has 6 rules 41 | expect(Polyglot.transformPhrase(arabic, 0, 'ar')).to.equal('ولا صوت') 42 | expect(Polyglot.transformPhrase(arabic, 1, 'ar')).to.equal('صوت واحد') 43 | expect(Polyglot.transformPhrase(arabic, 2, 'ar')).to.equal('صوتان') 44 | expect(Polyglot.transformPhrase(arabic, 3, 'ar')).to.equal('3 أصوات') 45 | expect(Polyglot.transformPhrase(arabic, 11, 'ar')).to.equal('11 صوت') 46 | expect(Polyglot.transformPhrase(arabic, 102, 'ar')).to.equal('102 صوت') 47 | }) 48 | 49 | it('defaults to `en`', () => { 50 | // French rule: "0" is singular 51 | expect(Polyglot.transformPhrase(english, { smart_count: 0 })).to.equal('0 Names') 52 | }) 53 | 54 | it('ignores a region subtag when choosing a pluralization rule', () => { 55 | // French rule: "0" is singular 56 | expect(Polyglot.transformPhrase(english, { smart_count: 0 }, 'fr-FR')).to.equal('0 Name') 57 | }) 58 | 59 | it('works without arguments', () => { 60 | expect(Polyglot.transformPhrase(english)).to.equal(english) 61 | }) 62 | 63 | it('respects a number as shortcut for smart_count', () => { 64 | expect(Polyglot.transformPhrase(english, 0, 'en')).to.equal('0 Names') 65 | expect(Polyglot.transformPhrase(english, 1, 'en')).to.equal('1 Name') 66 | expect(Polyglot.transformPhrase(english, 5, 'en')).to.equal('5 Names') 67 | }) 68 | 69 | it('throws without sane phrase string', () => { 70 | expect(() => { 71 | Polyglot.transformPhrase() 72 | }).to.throw(TypeError) 73 | expect(() => { 74 | Polyglot.transformPhrase(null) 75 | }).to.throw(TypeError) 76 | expect(() => { 77 | Polyglot.transformPhrase(32) 78 | }).to.throw(TypeError) 79 | expect(() => { 80 | Polyglot.transformPhrase({}) 81 | }).to.throw(TypeError) 82 | }) 83 | 84 | it('memoizes plural type language correctly and selects the correct locale after several calls', () => { 85 | expect(Polyglot.transformPhrase(english, { smart_count: 0 }, 'en')).to.equal('0 Names') 86 | expect(Polyglot.transformPhrase(english, { smart_count: 0 }, 'en')).to.equal('0 Names') 87 | expect(Polyglot.transformPhrase(english, { smart_count: 1 }, 'en')).to.equal('1 Name') 88 | expect(Polyglot.transformPhrase(english, { smart_count: 1 }, 'en')).to.equal('1 Name') 89 | 90 | expect(Polyglot.transformPhrase(english, { smart_count: 0 }, 'fr')).to.equal('0 Name') 91 | expect(Polyglot.transformPhrase(english, { smart_count: 0 }, 'fr')).to.equal('0 Name') 92 | expect(Polyglot.transformPhrase(english, { smart_count: 2 }, 'fr')).to.equal('2 Names') 93 | expect(Polyglot.transformPhrase(english, { smart_count: 2 }, 'fr')).to.equal('2 Names') 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /test/type.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vitest } from 'vitest' 2 | import { Polyglot } from '@productdevbook/ts-i18n' 3 | import type { I18nTranslations } from './.cache/i18n' 4 | 5 | // The two tests marked with concurrent will be run in parallel 6 | describe('type safety EN', () => { 7 | let polyglot: Polyglot 8 | beforeEach(() => { 9 | polyglot = new Polyglot({ 10 | locale: 'en', 11 | loaderOptions: { 12 | path: './test/.cache/locales', 13 | }, 14 | }) 15 | }) 16 | 17 | it('translates a simple string', () => { 18 | expect(polyglot.t('hello')).to.equal('Hello') 19 | }) 20 | 21 | it('returns the key if translation not found', () => { 22 | expect(polyglot.t('hi_name_welcome_to_place', { 23 | name: 'John', 24 | place: 'Vite', 25 | })).to.equal('Hi, John, welcome to Vite!') 26 | }) 27 | }) 28 | 29 | describe('type safety TR', () => { 30 | let polyglot: Polyglot 31 | beforeEach(() => { 32 | polyglot = new Polyglot({ 33 | locale: 'tr', 34 | loaderOptions: { 35 | path: './test/.cache/locales', 36 | }, 37 | }) 38 | }) 39 | 40 | it('translates a simple string', () => { 41 | expect(polyglot.t('hello')).to.equal('Merhaba') 42 | }) 43 | 44 | it('returns the key if translation not found', () => { 45 | expect(polyglot.t('hi_name_welcome_to_place', { 46 | name: 'John', 47 | place: 'Vite', 48 | })).to.equal('Merhaba John, Vite! hoşgeldin!') 49 | }) 50 | }) 51 | 52 | describe('errorOnMissing', () => { 53 | let polyglot: Polyglot 54 | beforeEach(() => { 55 | polyglot = new Polyglot({ 56 | locale: 'tr', 57 | loaderOptions: { 58 | path: './test/.cache/locales', 59 | }, 60 | errorOnMissing: true, 61 | }) 62 | }) 63 | 64 | it('translates a simple string', () => { 65 | expect(polyglot.t('hello')).to.equal('Merhaba') 66 | }) 67 | 68 | it('returns the key if translation not found', () => { 69 | expect(polyglot.t('hi_name_welcome_to_place', { 70 | name: 'John', 71 | place: 'Vite', 72 | })).to.equal('Merhaba John, Vite! hoşgeldin!') 73 | }) 74 | 75 | it('error variables', () => { 76 | const spy = vitest.spyOn(console, 'info').mockImplementation(() => { }) 77 | 78 | expect(polyglot.t('hi_name_welcome_to_place', { 79 | name: 'John', 80 | })) 81 | 82 | expect(spy).toHaveBeenCalled() 83 | expect(spy.mock.calls[0][0]).include('hi_name_welcome_to_place') 84 | expect(spy.mock.calls[0][0]).include('place') 85 | }) 86 | 87 | it('error two variables ', () => { 88 | const spy = vitest.spyOn(console, 'info').mockImplementation(() => { }) 89 | 90 | expect(polyglot.t('hi_name_welcome_to_place', { 91 | })) 92 | 93 | expect(spy).toHaveBeenCalled() 94 | expect(spy.mock.calls[0][0]).include('hi_name_welcome_to_place') 95 | expect(spy.mock.calls[0][0]).toMatch(/'name'/) 96 | 97 | expect(spy.mock.calls[1][0]).include('hi_name_welcome_to_place') 98 | expect(spy.mock.calls[1][0]).toMatch(/'place'/) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Node 18", 4 | "_version": "2.0.0", 5 | "compilerOptions": { 6 | "target": "es2022", 7 | "lib": [ 8 | "ESNext" 9 | ], 10 | "baseUrl": ".", 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "paths": { 14 | "@productdevbook/ts-i18n": [ 15 | "src/index.ts" 16 | ] 17 | }, 18 | "resolveJsonModule": true, 19 | "strict": true, 20 | "esModuleInterop": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "skipLibCheck": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'tsup' 2 | 3 | import pkg from './package.json' 4 | 5 | const external = [ 6 | ...Object.keys(pkg.peerDependencies || {}), 7 | ...Object.keys(pkg.dependencies || {}), 8 | ] 9 | 10 | export default { 11 | entryPoints: [ 12 | 'src/index.ts', 13 | 'src/unplugin/*.ts', 14 | ], 15 | outDir: 'dist', 16 | target: 'node18', 17 | format: ['esm', 'cjs'], 18 | clean: true, 19 | dts: true, 20 | minify: true, 21 | external, 22 | } 23 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: 'v8', 7 | reporter: ['text', 'json-summary', 'json', 'html'], 8 | }, 9 | exclude: [ 10 | '**/node_modules/**', 11 | '**/dist/**', 12 | '**/.cache/**', 13 | ], 14 | include: [ 15 | './test/**', 16 | ], 17 | alias: { 18 | '@productdevbook/ts-i18n': 'src/index.ts', 19 | }, 20 | }, 21 | }) 22 | --------------------------------------------------------------------------------