| 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 |
--------------------------------------------------------------------------------