├── .husky └── pre-commit ├── test ├── fixtures │ ├── fixture2 │ │ ├── en │ │ │ ├── front.json │ │ │ └── common.json │ │ └── de │ │ │ └── common.json │ ├── fixture3 │ │ ├── de │ │ │ ├── common.json │ │ │ └── nested │ │ │ │ └── a.json │ │ ├── en │ │ │ ├── nested │ │ │ │ ├── a.json │ │ │ │ └── b.json │ │ │ └── common.json │ │ └── he │ │ │ └── common.json │ ├── fixture4 │ │ ├── front │ │ │ ├── en.json │ │ │ └── he.json │ │ └── common │ │ │ ├── he.json │ │ │ └── en.json │ ├── fixture5 │ │ ├── front │ │ │ ├── en.json │ │ │ └── he.json │ │ └── common │ │ │ ├── he.json │ │ │ └── en.json │ ├── fixture6 │ │ ├── hebrew │ │ │ ├── en.json │ │ │ └── he.json │ │ └── common │ │ │ ├── he.json │ │ │ └── en.json │ ├── fixture7 │ │ ├── en-uk │ │ │ ├── front.json │ │ │ └── common.json │ │ └── de-at │ │ │ └── common.json │ ├── fixture1 │ │ ├── he.json │ │ └── en.json │ └── fixture8 │ │ ├── ar │ │ └── common.json │ │ └── he │ │ └── common.json ├── syncJson │ ├── plurals-v3.test.ts │ ├── plurals.test.ts │ └── basicKeys.test.ts ├── syncLocales.test.ts ├── generateLocaleFiles.test.ts └── __snapshots__ │ └── syncLocales.test.ts.snap ├── .gitignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── src ├── constats.ts ├── utils │ ├── hasSomePluralSuffix.ts │ ├── isObject.ts │ ├── traverse.ts │ ├── generatePluralForms.ts │ └── writeToDisk.ts ├── syncLocaleFiles.ts ├── index.ts ├── syncJson.ts ├── cli.ts ├── generateLocaleFiles.ts └── i18next │ ├── LanguageUtils.ts │ └── PluralResolver.ts ├── types ├── globals.d.ts └── types.d.ts ├── .prettierrc ├── LICENSE ├── __mocks__ └── fs-extra │ └── index.js ├── tsconfig.json ├── package.json ├── README.md └── CHANGELOG.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn tsdx lint 2 | -------------------------------------------------------------------------------- /test/fixtures/fixture2/en/front.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "front" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture3/de/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-de" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture3/de/nested/a.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": "bla-de" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture3/en/nested/a.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": "bla-en" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture3/en/nested/b.json: -------------------------------------------------------------------------------- 1 | { 2 | "b": "bla-en" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture4/front/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-en" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture5/front/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-en" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture6/hebrew/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-en" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture7/en-uk/front.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "front" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | .idea 6 | coverage 7 | -------------------------------------------------------------------------------- /test/fixtures/fixture1/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla", 3 | "test_one": "bla-0" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture5/front/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla", 3 | "test_0": "bla-0" 4 | } 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [felixmosh] 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture4/front/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla", 3 | "test_few": "bla-few" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture6/hebrew/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla", 3 | "test_few": "bla-few" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture2/de/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-de", 3 | "test_one": "bla-de" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture4/common/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-he", 3 | "test_many": "bla-many" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture5/common/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-he", 3 | "test_1": "bla-1-he" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture6/common/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-he", 3 | "test_many": "bla-many" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture7/de-at/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-de", 3 | "test_one": "bla-de" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture3/he/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_two": "bla-two-he", 3 | "test": "bla-he" 4 | } 5 | -------------------------------------------------------------------------------- /src/constats.ts: -------------------------------------------------------------------------------- 1 | export const MAX_DEPTH = 20; 2 | 3 | export const LIB_PREFIX = '[ i18next-locales-sync ]:'; 4 | -------------------------------------------------------------------------------- /test/fixtures/fixture5/common/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-en", 3 | "test_plural": "bla-plural-en" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture8/ar/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-ar", 3 | "test_plural": "bla-ar-plural" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture8/he/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla-he", 3 | "test_plural": "bla-he-plural" 4 | } 5 | -------------------------------------------------------------------------------- /types/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.json' { 2 | const resource: Record; 3 | export = resource; 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/fixture1/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla en", 3 | "test_one": "bla one en", 4 | "test_other": "bla other en" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/fixture2/en/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla en", 3 | "test_one": "bla one en", 4 | "test_other": "bla other en" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/fixture3/en/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla en", 3 | "test_one": "bla one en", 4 | "test_other": "bla other en" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/fixture4/common/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla en", 3 | "test_one": "bla one en", 4 | "test_other": "bla other en" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/fixture6/common/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla en", 3 | "test_one": "bla one en", 4 | "test_other": "bla other en" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/fixture7/en-uk/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "bla en", 3 | "test_one": "bla one en", 4 | "test_other": "bla other en" 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/hasSomePluralSuffix.ts: -------------------------------------------------------------------------------- 1 | export function hasSomePluralSuffix(key: string, suffixes: string[]): boolean { 2 | return suffixes.some((suffix) => key.endsWith(suffix)); 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/isObject.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject } from '../../types/types'; 2 | 3 | export function isObject(maybeObj: any): maybeObj is JSONObject { 4 | return maybeObj !== null && typeof maybeObj === 'object'; 5 | } 6 | -------------------------------------------------------------------------------- /types/types.d.ts: -------------------------------------------------------------------------------- 1 | export type JSONValue = JSONObject | Array | string | number | null; 2 | 3 | export type JSONObject = { [key: string]: JSONValue }; 4 | 5 | export type LocaleObject = { language: string; data: JSONObject }; 6 | 7 | export type LocaleFile = { 8 | filePath: string; 9 | data: JSONObject; 10 | hash: string; 11 | }; 12 | 13 | export type LocalesFiles = Record>; 14 | 15 | export type CompatibilityJSON = 'v1' | 'v2' | 'v3' | 'v4'; 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: felixmosh 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Environment (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Version [e.g. 22] 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['18.x', '20.x'] 11 | os: [ubuntu-latest, windows-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v4 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node }} 21 | cache: 'yarn' 22 | 23 | - name: Install deps 24 | run: yarn install --frozen-lockfile --silent 25 | env: 26 | CI: true 27 | 28 | - name: Lint 29 | run: yarn lint 30 | 31 | - name: Test 32 | run: yarn test --ci --coverage --maxWorkers=2 33 | 34 | - name: Build 35 | run: yarn build 36 | -------------------------------------------------------------------------------- /src/utils/traverse.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject, JSONValue } from '../../types/types'; 2 | import { LIB_PREFIX, MAX_DEPTH } from '../constats'; 3 | import { isObject } from './isObject'; 4 | 5 | export function traverse( 6 | source: JSONObject, 7 | target: JSONObject, 8 | fn: ( 9 | sourceObj: JSONObject, 10 | targetObj: JSONObject, 11 | newTargetObj: JSONObject, 12 | prop: string, 13 | sourceValue: JSONValue, 14 | targetValue: JSONValue 15 | ) => any, 16 | depth = MAX_DEPTH, 17 | finalObj: any = {} 18 | ): JSONObject { 19 | if (depth === 0) { 20 | throw new Error(`${LIB_PREFIX} given json with depth that is deeper than ${MAX_DEPTH}`); 21 | } 22 | 23 | for (const key in source) { 24 | if (!source.hasOwnProperty(key)) { 25 | continue; 26 | } 27 | 28 | const sourceValue = source[key]; 29 | fn.apply(null, [source, target, finalObj, key, source[key], target && target[key]]); 30 | if (isObject(sourceValue)) { 31 | traverse(sourceValue, target && (target[key] as any), fn, depth - 1, finalObj[key]); 32 | } 33 | } 34 | 35 | return finalObj; 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Felix Mosheev 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. -------------------------------------------------------------------------------- /src/syncLocaleFiles.ts: -------------------------------------------------------------------------------- 1 | import { LocalesFiles } from '../types/types'; 2 | import { PluralResolver } from './i18next/PluralResolver'; 3 | import { syncJson } from './syncJson'; 4 | 5 | interface IOptions { 6 | localeFiles: LocalesFiles; 7 | primaryLanguage: string; 8 | otherLanguages: string[]; 9 | pluralResolver: PluralResolver; 10 | useEmptyString?: boolean; 11 | } 12 | 13 | export function syncLocaleFiles({ 14 | localeFiles, 15 | primaryLanguage, 16 | otherLanguages, 17 | pluralResolver, 18 | useEmptyString, 19 | }: IOptions): LocalesFiles { 20 | const primaryLocaleFiles = localeFiles[primaryLanguage]; 21 | otherLanguages.forEach((currentLanguage) => { 22 | const currentNamespaces = localeFiles[currentLanguage]; 23 | 24 | Object.keys(primaryLocaleFiles).forEach((primaryNamespace) => { 25 | const { data } = syncJson({ 26 | source: { data: primaryLocaleFiles[primaryNamespace].data, language: primaryLanguage }, 27 | target: { data: currentNamespaces[primaryNamespace].data, language: currentLanguage }, 28 | pluralResolver, 29 | useEmptyString, 30 | }); 31 | 32 | currentNamespaces[primaryNamespace].data = data; 33 | }); 34 | }); 35 | 36 | return localeFiles; 37 | } 38 | -------------------------------------------------------------------------------- /__mocks__/fs-extra/index.js: -------------------------------------------------------------------------------- 1 | const { vol, fs: memfs } = require('memfs'); 2 | const glob = require('glob'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | 6 | const basePath = path.resolve('./test/fixtures'); 7 | const fixtures = glob.sync(path.join(basePath, '/**/*.json')); 8 | 9 | const fileSystemJson = fixtures.reduce((result, filepath) => { 10 | const relativePath = path.relative(basePath, filepath); 11 | result[`./${relativePath}`] = fs.readFileSync(filepath, { encoding: 'utf8' }); 12 | return result; 13 | }, {}); 14 | 15 | vol.fromJSON(fileSystemJson, basePath); 16 | 17 | module.exports = { 18 | existsSync: function mockExistsSync(filepath) { 19 | return memfs.existsSync(filepath); 20 | }, 21 | ensureFileSync: function mockEnsureFileSync(filepath) { 22 | if (!memfs.existsSync(filepath)) { 23 | memfs.mkdirSync(path.dirname(filepath), { recursive: true }); 24 | memfs.writeFileSync(filepath, ''); 25 | } 26 | }, 27 | readJSONSync: function mockReadJSONSync(filepath, options) { 28 | const data = memfs.readFileSync(filepath, options); 29 | return JSON.parse(data); 30 | }, 31 | writeJSONSync: function mockWriteJSONSync(filepath, data, options) { 32 | return memfs.writeFileSync(filepath, JSON.stringify(data, null, options.spaces)); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2019", 5 | "lib": [ 6 | "esnext" 7 | ], 8 | "importHelpers": false, 9 | // output .d.ts declaration files for consumers 10 | "declaration": true, 11 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 12 | "rootDir": "./src", 13 | "outDir": "./dist", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // interop between ESM and CJS modules. Recommended by TS 25 | "esModuleInterop": true, 26 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 27 | "skipLibCheck": true, 28 | // error out if import and file system have a casing mismatch. Recommended by TS 29 | "forceConsistentCasingInFileNames": true 30 | }, 31 | "include": [ 32 | "src", 33 | "types" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/generatePluralForms.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject } from '../../types/types'; 2 | import { PluralResolver } from '../i18next/PluralResolver'; 3 | import { isObject } from './isObject'; 4 | 5 | interface Options { 6 | sourceObject: JSONObject; 7 | targetObject: JSONObject; 8 | newTargetObject: JSONObject; 9 | sourceKey: string; 10 | sourceLng: string; 11 | targetLng: string; 12 | } 13 | 14 | export function generatePluralForms( 15 | { sourceKey, sourceLng, targetLng, newTargetObject, targetObject, sourceObject }: Options, 16 | pluralResolver: PluralResolver, 17 | { 18 | useEmptyString = false, 19 | isTargetRequiresPluralForm, 20 | }: Partial<{ useEmptyString: boolean; isTargetRequiresPluralForm: boolean }> = {} 21 | ) { 22 | const singularSourceKey = pluralResolver.getSingularFormOfKey(sourceLng, sourceKey); 23 | 24 | if (isTargetRequiresPluralForm) { 25 | const pluralForms = pluralResolver.getPluralFormsOfKey(targetLng, singularSourceKey); 26 | 27 | const fallbackValue = useEmptyString ? '' : sourceObject[sourceKey]; 28 | 29 | pluralForms.forEach((key) => { 30 | newTargetObject[key] = 31 | (targetObject && targetObject[key] && !isObject(targetObject[key]) 32 | ? targetObject[key] 33 | : useEmptyString 34 | ? '' 35 | : sourceObject[key]) || fallbackValue; 36 | }); 37 | } else if ( 38 | targetObject && 39 | targetObject[singularSourceKey] && 40 | !isObject(targetObject[singularSourceKey]) 41 | ) { 42 | newTargetObject[singularSourceKey] = targetObject[singularSourceKey]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/writeToDisk.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { LocalesFiles } from '../../types/types'; 3 | import path from 'path'; 4 | import fs, { WriteOptions } from 'fs-extra'; 5 | 6 | interface Options { 7 | localeFiles: LocalesFiles; 8 | primaryLanguage: string; 9 | otherLanguages: string[]; 10 | outputFolder: string; 11 | localesFolder: string; 12 | spaces: WriteOptions['spaces']; 13 | } 14 | 15 | export function writeToDisk({ 16 | localeFiles, 17 | primaryLanguage, 18 | otherLanguages, 19 | localesFolder, 20 | outputFolder, 21 | spaces, 22 | }: Options) { 23 | const primaryLocaleFile = localeFiles[primaryLanguage]; 24 | 25 | [primaryLanguage].concat(otherLanguages).forEach((otherLanguage) => { 26 | Object.keys(primaryLocaleFile).forEach((primaryNamespace) => { 27 | const otherLanguageLocaleFile = localeFiles[otherLanguage][primaryNamespace]; 28 | 29 | const filePath = path.relative(localesFolder, otherLanguageLocaleFile.filePath); 30 | const outputFilePath = path.join(outputFolder, filePath); 31 | 32 | if ( 33 | localesFolder !== outputFolder || 34 | otherLanguageLocaleFile.hash === '' || 35 | otherLanguageLocaleFile.hash !== 36 | crypto 37 | .createHash('md5') 38 | .update(JSON.stringify(otherLanguageLocaleFile.data)) 39 | .digest('hex') 40 | ) { 41 | fs.ensureFileSync(outputFilePath); 42 | fs.writeJSONSync(outputFilePath, otherLanguageLocaleFile.data, { 43 | spaces, 44 | encoding: 'utf-8', 45 | }); 46 | } 47 | }); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { WriteOptions } from 'fs-extra'; 3 | import { CompatibilityJSON } from '../types/types'; 4 | import { LIB_PREFIX } from './constats'; 5 | import { generateLocaleFiles } from './generateLocaleFiles'; 6 | import { PluralResolver } from './i18next/PluralResolver'; 7 | import { syncLocaleFiles } from './syncLocaleFiles'; 8 | import { writeToDisk } from './utils/writeToDisk'; 9 | 10 | interface SyncLocalesOptions { 11 | primaryLanguage: string; 12 | secondaryLanguages: string[]; 13 | localesFolder: string; 14 | outputFolder?: string; 15 | fileExtension?: string; 16 | overridePluralRules?: (pluralResolver: PluralResolver) => void; 17 | useEmptyString?: boolean; 18 | spaces?: WriteOptions['spaces']; 19 | compatibilityJSON?: CompatibilityJSON; 20 | } 21 | 22 | export function syncLocales({ 23 | primaryLanguage, 24 | secondaryLanguages: otherLanguages, 25 | localesFolder, 26 | outputFolder = localesFolder, 27 | overridePluralRules, 28 | fileExtension = '.json', 29 | useEmptyString = false, 30 | spaces = 2, 31 | compatibilityJSON = 'v4', 32 | }: SyncLocalesOptions) { 33 | const pluralResolver = new PluralResolver({ compatibilityJSON }); 34 | 35 | if (typeof overridePluralRules === 'function') { 36 | overridePluralRules(pluralResolver); 37 | } 38 | 39 | const localeFiles = generateLocaleFiles({ 40 | primaryLanguage, 41 | otherLanguages, 42 | localesFolder, 43 | fileExtension, 44 | }); 45 | 46 | syncLocaleFiles({ 47 | localeFiles, 48 | primaryLanguage, 49 | otherLanguages, 50 | pluralResolver, 51 | useEmptyString, 52 | }); 53 | 54 | writeToDisk({ 55 | localeFiles, 56 | primaryLanguage, 57 | otherLanguages, 58 | outputFolder, 59 | localesFolder, 60 | spaces, 61 | }); 62 | 63 | console.log( 64 | chalk.green`${chalk.bold.greenBright(LIB_PREFIX)} '${localesFolder}' were synced successfully.` 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.1.1", 3 | "name": "i18next-locales-sync", 4 | "description": "Syncs i18next locale resource files against a primary language.\n", 5 | "keywords": [ 6 | "i18next", 7 | "locale", 8 | "sync" 9 | ], 10 | "author": "felixmosh", 11 | "license": "MIT", 12 | "homepage": "https://github.com/felixmosh/i18next-locales-sync", 13 | "bugs": "https://github.com/felixmosh/i18next-locales-sync/issues", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/felixmosh/i18next-locales-sync" 17 | }, 18 | "main": "dist/index.js", 19 | "bin": "dist/cli.js", 20 | "typings": "dist/index.d.ts", 21 | "files": [ 22 | "dist", 23 | "types" 24 | ], 25 | "engines": { 26 | "node": ">=12" 27 | }, 28 | "scripts": { 29 | "start": "tsdx watch --target=node", 30 | "build": "rm -rf dist && tsc", 31 | "test": "tsdx test", 32 | "lint": "tsdx lint", 33 | "version": "auto-changelog -p && git add CHANGELOG.md", 34 | "release": "release-it --only-version", 35 | "prepare": "husky" 36 | }, 37 | "dependencies": { 38 | "chalk": "^4.1.2", 39 | "fdir": "^6.1.1", 40 | "fs-extra": "^10.0.0", 41 | "picomatch": "^4.0.2", 42 | "yargs": "^17.5.1" 43 | }, 44 | "devDependencies": { 45 | "@types/fs-extra": "^9.0.13", 46 | "@types/picomatch": "^2.3.4", 47 | "auto-changelog": "^2.4.0", 48 | "husky": "^9.0.11", 49 | "memfs": "^4.9.3", 50 | "release-it": "^16.3.0", 51 | "tsdx": "^0.14.1", 52 | "typescript": "^5.5.3" 53 | }, 54 | "release-it": { 55 | "git": { 56 | "changelog": "npx auto-changelog --stdout --commit-limit false -u --template https://raw.githubusercontent.com/release-it/release-it/master/templates/changelog-compact.hbs" 57 | }, 58 | "hooks": { 59 | "before:init": [ 60 | "yarn test", 61 | "yarn lint", 62 | "yarn build" 63 | ], 64 | "after:bump": "npx auto-changelog -p" 65 | }, 66 | "github": { 67 | "release": true 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/syncJson.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject, JSONValue, LocaleObject } from '../types/types'; 2 | import { MAX_DEPTH } from './constats'; 3 | import { PluralResolver } from './i18next/PluralResolver'; 4 | import { generatePluralForms } from './utils/generatePluralForms'; 5 | import { hasSomePluralSuffix } from './utils/hasSomePluralSuffix'; 6 | import { isObject } from './utils/isObject'; 7 | import { traverse } from './utils/traverse'; 8 | 9 | interface IEntryOptions { 10 | sourceLng: string; 11 | targetLng: string; 12 | pluralResolver: PluralResolver; 13 | useEmptyString?: boolean; 14 | } 15 | 16 | function syncEntry({ sourceLng, targetLng, pluralResolver, useEmptyString }: IEntryOptions) { 17 | const sourceSuffixes = pluralResolver.getPluralFormsOfKey(sourceLng, '').filter(Boolean); 18 | const targetSuffixes = pluralResolver.getPluralFormsOfKey(targetLng, '').filter(Boolean); 19 | 20 | const isTargetRequiresPluralForm = pluralResolver.needsPlural(targetLng); 21 | 22 | return ( 23 | sourceObject: JSONObject, 24 | targetObject: JSONObject, 25 | newTargetObject: JSONObject, 26 | sourceKey: string, 27 | sourceValue: JSONValue, 28 | targetValue: JSONValue 29 | ) => { 30 | if (Array.isArray(sourceValue)) { 31 | newTargetObject[sourceKey] = []; 32 | } else if (isObject(sourceValue)) { 33 | newTargetObject[sourceKey] = {}; 34 | } else if (hasSomePluralSuffix(sourceKey, sourceSuffixes)) { 35 | generatePluralForms( 36 | { 37 | sourceObject, 38 | targetObject, 39 | newTargetObject, 40 | sourceKey, 41 | sourceLng, 42 | targetLng, 43 | }, 44 | pluralResolver, 45 | { useEmptyString, isTargetRequiresPluralForm } 46 | ); 47 | } else { 48 | newTargetObject[sourceKey] = 49 | targetValue && !isObject(targetValue) ? targetValue : useEmptyString ? '' : sourceValue; 50 | 51 | // keeps existing plural forms 52 | targetSuffixes.forEach((suffix) => { 53 | const pluralTargetKey = `${sourceKey}${suffix}`; 54 | 55 | if (targetObject && targetObject[pluralTargetKey]) { 56 | newTargetObject[pluralTargetKey] = targetObject[pluralTargetKey]; 57 | } 58 | }); 59 | } 60 | }; 61 | } 62 | 63 | interface SyncJsonOptions { 64 | source: LocaleObject; 65 | target: LocaleObject; 66 | pluralResolver: PluralResolver; 67 | depth?: number; 68 | useEmptyString?: boolean; 69 | } 70 | 71 | export function syncJson({ 72 | source, 73 | target, 74 | pluralResolver, 75 | depth = MAX_DEPTH, 76 | useEmptyString = false, 77 | }: SyncJsonOptions) { 78 | target.data = traverse( 79 | source.data, 80 | target.data, 81 | syncEntry({ 82 | sourceLng: source.language, 83 | targetLng: target.language, 84 | pluralResolver, 85 | useEmptyString, 86 | }), 87 | depth 88 | ); 89 | 90 | return target; 91 | } 92 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs-extra'; 4 | import * as path from 'path'; 5 | import yargs from 'yargs'; 6 | import { CompatibilityJSON } from '../types/types'; 7 | import { LIB_PREFIX } from './constats'; 8 | import chalk from 'chalk'; 9 | import { syncLocales } from './index'; 10 | 11 | const options = yargs.usage('i18next-locales-sync -p en -s de ja he -l ./path/to/locales').option({ 12 | primaryLanguage: { 13 | alias: 'p', 14 | type: 'string', 15 | description: 'The primary (source) language', 16 | default: 'en', 17 | }, 18 | secondaryLanguages: { 19 | alias: 's', 20 | description: 'A list of all other supported languages', 21 | type: 'array', 22 | default: [], 23 | }, 24 | localesFolder: { 25 | alias: 'l', 26 | description: 'The locals folder path (can be relative)', 27 | type: 'string', 28 | normalize: true, 29 | }, 30 | outputFolder: { 31 | alias: 'o', 32 | description: 'The output folder', 33 | defaultDescription: '`localesFolder`', 34 | type: 'string', 35 | normalize: true, 36 | }, 37 | config: { 38 | alias: 'c', 39 | description: 'A path to the config file', 40 | type: 'string', 41 | normalize: true, 42 | }, 43 | useEmptyString: { 44 | alias: 'e', 45 | description: 'Use empty string as a value for new keys', 46 | type: 'boolean', 47 | normalize: true, 48 | default: false, 49 | defaultDescription: '`false`', 50 | }, 51 | spaces: { 52 | description: 'Number of indentation spaces in json output', 53 | type: 'number', 54 | normalize: true, 55 | default: 2, 56 | }, 57 | compatibilityJSON: { 58 | alias: 'j', 59 | description: 'I18next json version', 60 | type: 'string', 61 | normalize: true, 62 | default: 'v4', 63 | }, 64 | }).argv; 65 | 66 | if (options.config) { 67 | options.config = path.resolve(options.config); 68 | if (!fs.existsSync(options.config)) { 69 | throw new Error(chalk.red`${LIB_PREFIX} Config file '${options.config}' doesn't exist`); 70 | } 71 | 72 | const usersOptions = require(options.config); 73 | 74 | Object.assign(options, usersOptions); 75 | } 76 | 77 | if (!options.localesFolder) { 78 | throw new Error(chalk.red`${LIB_PREFIX} 'localesFolder' is mandatory option`); 79 | } else { 80 | options.localesFolder = path.resolve(options.localesFolder); 81 | 82 | if (!fs.existsSync(options.localesFolder)) { 83 | throw new Error( 84 | chalk.red`${LIB_PREFIX} Locales folder '${options.localesFolder}' doesn't exist` 85 | ); 86 | } 87 | } 88 | 89 | if (options.outputFolder) { 90 | options.outputFolder = path.resolve(options.outputFolder); 91 | } 92 | 93 | syncLocales({ 94 | primaryLanguage: options.primaryLanguage, 95 | secondaryLanguages: options.secondaryLanguages, 96 | localesFolder: options.localesFolder, 97 | outputFolder: options.outputFolder, 98 | overridePluralRules: options.overridePluralRules as any, 99 | useEmptyString: options.useEmptyString, 100 | spaces: options.spaces, 101 | compatibilityJSON: options.compatibilityJSON as CompatibilityJSON, 102 | }); 103 | -------------------------------------------------------------------------------- /src/generateLocaleFiles.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import fs from 'fs-extra'; 3 | import { fdir } from 'fdir'; 4 | import picomatch from 'picomatch'; 5 | import path from 'path'; 6 | import { LocaleFile, LocalesFiles } from '../types/types'; 7 | import { LIB_PREFIX } from './constats'; 8 | 9 | interface Options { 10 | primaryLanguage: string; 11 | otherLanguages: string[]; 12 | localesFolder: string; 13 | fileExtension: string; 14 | } 15 | 16 | function extractLanguagesFromPath(filepath: string, allLanguages: string[]) { 17 | const pathParts = filepath.split(/[\\/]/g); 18 | return allLanguages.find((language) => pathParts.some((part) => part === language)); 19 | } 20 | 21 | function extractNamespaceFromPath(filepath: string, language: string) { 22 | const pathParts = filepath.split(/[\\/]/g); 23 | 24 | if (pathParts.length < 2) { 25 | // handle empty namespace 26 | pathParts.push(''); 27 | } 28 | 29 | const namespaceParts = pathParts.filter((part) => part !== language); 30 | 31 | return namespaceParts.join('/'); 32 | } 33 | 34 | function addMissingLanguages(localeFiles: LocalesFiles, otherLanguages: string[]) { 35 | otherLanguages.filter((lang) => !localeFiles[lang]).forEach((lang) => (localeFiles[lang] = {})); 36 | 37 | return localeFiles; 38 | } 39 | 40 | function addMissingNamespaces( 41 | localeFiles: LocalesFiles, 42 | primaryLanguage: string, 43 | otherLanguages: string[] 44 | ) { 45 | const primaryFiles = localeFiles[primaryLanguage]; 46 | 47 | Object.keys(primaryFiles).forEach((namespace) => { 48 | otherLanguages.forEach((otherLanguage) => { 49 | const filePath = primaryFiles[namespace].filePath 50 | .split(/[\\/]/g) 51 | .map((part) => { 52 | if (part.startsWith(primaryLanguage)) { 53 | return part.replace(primaryLanguage, otherLanguage); 54 | } 55 | return part; 56 | }) 57 | .join(path.sep); 58 | 59 | localeFiles[otherLanguage][namespace] = localeFiles[otherLanguage][namespace] || { 60 | filePath, 61 | hash: '', 62 | data: {}, 63 | }; 64 | }); 65 | }); 66 | } 67 | 68 | export function populateFromDisk(filePath: string): LocaleFile { 69 | let data = {}; 70 | let hash = ''; 71 | 72 | if (fs.existsSync(filePath)) { 73 | data = fs.readJSONSync(filePath, { encoding: 'utf8' }); 74 | 75 | hash = crypto 76 | .createHash('md5') 77 | .update(JSON.stringify(data)) 78 | .digest('hex'); 79 | } 80 | 81 | return { filePath, data, hash }; 82 | } 83 | 84 | export function generateLocaleFiles({ 85 | localesFolder, 86 | fileExtension, 87 | primaryLanguage, 88 | otherLanguages, 89 | }: Options): LocalesFiles { 90 | const matcher = picomatch(`**/*${fileExtension}`); 91 | const paths = new fdir() 92 | .withFullPaths() 93 | .filter((path) => matcher(path)) 94 | .crawl(localesFolder) 95 | .sync(); 96 | const allLanguages = [primaryLanguage].concat(otherLanguages); 97 | 98 | const localeFiles = paths.reduce((structure, filePath) => { 99 | const remainingPath = path.relative(localesFolder, filePath); 100 | const filename = path.basename(remainingPath, fileExtension); 101 | const filepathWithoutExtension = path.join(path.dirname(remainingPath), filename); 102 | const language = extractLanguagesFromPath(filepathWithoutExtension, allLanguages); 103 | 104 | if (!language) { 105 | return structure; 106 | } 107 | 108 | const namespace = extractNamespaceFromPath(filepathWithoutExtension, language); 109 | 110 | structure[language] = structure[language] || {}; 111 | structure[language][namespace] = populateFromDisk(filePath); 112 | 113 | return structure; 114 | }, {} as LocalesFiles); 115 | 116 | if (!localeFiles[primaryLanguage]) { 117 | throw new Error( 118 | `${LIB_PREFIX} There are no files for your primary language (${primaryLanguage})` 119 | ); 120 | } 121 | 122 | addMissingLanguages(localeFiles, otherLanguages); 123 | addMissingNamespaces(localeFiles, primaryLanguage, otherLanguages); 124 | 125 | return localeFiles; 126 | } 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # i18next-locales-sync 2 | 3 | ![CI](https://github.com/felixmosh/i18next-locales-sync/workflows/CI/badge.svg) 4 | [![npm](https://img.shields.io/npm/v/i18next-locales-sync.svg)](https://www.npmjs.com/package/i18next-locales-sync) 5 | 6 | Syncs [i18next](https://github.com/i18next/i18next) locale resource files against a primary language. 7 | 8 | ## Installation 9 | 10 | ```sh 11 | $ npm install --save-dev i18next-locales-sync 12 | ``` 13 | 14 | ## Features 15 | 16 | 1. Supports [namespaces](https://www.i18next.com/principles/namespaces). 17 | 2. Full plural support, based on the real [i18next pluralResolver](https://github.com/felixmosh/i18next-locales-sync/blob/master/src/i18next/PluralResolver.ts). 18 | 3. Supports JSON v4 19 | 4. Sorting secondary locale keys by primary language order. 20 | 5. Supports multiple locale folder structure, `{lng}/{namespace}`, `{namespace}/{lng}`. 21 | 6. Creates missing locale files. 22 | 7. Allows overriding plural rules. 23 | 24 | ## Usage 25 | 26 | ### 1. CLI 27 | 28 | ```sh 29 | $ npx i18next-locales-sync -p he -s en de ja -l path/to/locales/folder --spaces 2 30 | ``` 31 | 32 | or using config file 33 | 34 | ```js 35 | // localesSync.config.js 36 | module.exports = { 37 | primaryLanguage: 'he', 38 | secondaryLanguages: ['en', 'de', 'ja'], 39 | localesFolder: './path/to/locales/folder', 40 | overridePluralRules: (pluralResolver) => 41 | pluralResolver.addRule('he', pluralResolver.getRule('en')), // This is available only when using config file 42 | spaces: 2, 43 | }; 44 | ``` 45 | 46 | ```sh 47 | $ npx i18next-locales-sync -c ./localesSync.config.js 48 | ``` 49 | 50 | ### 2. Node 51 | 52 | ```js 53 | import { syncLocales } from 'i18next-locales-sync'; 54 | import path from 'path'; 55 | 56 | syncLocales({ 57 | primaryLanguage: 'en', 58 | secondaryLanguages: ['en', 'de', 'ja'], 59 | localesFolder: path.resolve('./path/to/locales/folder'), 60 | overridePluralRules: (pluralResolver) => 61 | pluralResolver.addRule('he', pluralResolver.getRule('en')), 62 | }); 63 | ``` 64 | 65 | ## Options 66 | 67 | | Key | Type | Default value | 68 | | ------------------- |-------------------------------------------------------|-----------------| 69 | | primaryLanguage | `string` | | 70 | | secondaryLanguages | `string[]` | | 71 | | localesFolder | `string` | | 72 | | outputFolder | `string?` | `localesFolder` | 73 | | overridePluralRules | `(pluralResolver: PluralResolver)? => PluralResolver` | | 74 | | useEmptyString | `boolean` | `false` | 75 | | spaces | `number` | `2` | 76 | | compatibilityJSON | `string` | `v4` | 77 | 78 | Currently, the lib supports only `.json` locale files, PRs are welcome :]. 79 | 80 | ## Example 81 | 82 | Given these files: 83 | 84 | ```sh 85 | examples 86 | ├── en 87 | │ └── namespace.json 88 | ├── he 89 | │ └── namespace.json 90 | └── ja 91 | └── namespace.json 92 | ``` 93 | 94 | ```json 95 | // en/namespace.json 96 | { 97 | "foo_male": "bar-male-en", 98 | "room_one": "room", 99 | "room_other": "rooms" 100 | } 101 | ``` 102 | 103 | ```json 104 | // he/namespace.json 105 | { 106 | "room": "חדר", 107 | "foo_male": "bar-male-he", 108 | "room_few": "חדרים" 109 | } 110 | ``` 111 | 112 | ```json 113 | // ja/namespace.json 114 | { 115 | "foo_male": "bar-male-ja", 116 | "room": "部屋", 117 | "room_other": "部屋" 118 | } 119 | ``` 120 | 121 | Syncying `he` & `ja` against `en` 122 | 123 | ```sh 124 | $ npx i18next-locales-sync -p en -s he ja -l ./examples/ 125 | ``` 126 | 127 | Will result with 128 | 129 | ```json 130 | // en/namespace.json 131 | 132 | // `en` remains untouched 133 | { 134 | "foo_male": "bar-male-en", 135 | "room_one": "room", 136 | "room_other": "rooms" 137 | } 138 | ``` 139 | 140 | ```json 141 | // he/namespace.json 142 | 143 | // sorted based on the primary lang file 144 | // keeps existing plural form (room_3) 145 | // added missing plural forms 146 | { 147 | "foo_male": "bar-male-he", 148 | "room_one": "חדר", 149 | "room_two": "חדרים", 150 | "room_few": "rooms", 151 | "room_other": "rooms" 152 | } 153 | ``` 154 | 155 | ```json 156 | // ja/namespace.json 157 | 158 | // keeps exising fields 159 | // removed plural form since there is no plural form in Japanese 160 | { 161 | "foo_male": "bar-male-ja", 162 | "room": "部屋" 163 | } 164 | ``` 165 | 166 | ### Prior art 167 | 168 | 1. [i18next-json-sync](https://github.com/jwbay/i18next-json-sync) 169 | -------------------------------------------------------------------------------- /src/i18next/LanguageUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extracted from https://github.com/i18next/i18next/blob/master/src/LanguageUtils.js 3 | */ 4 | 5 | function capitalize(string: string) { 6 | return string.charAt(0).toUpperCase() + string.slice(1); 7 | } 8 | 9 | interface LanguageUtilsOptions { 10 | supportedLngs?: string[] | false; 11 | fallbackLng?: string; 12 | lowerCaseLng?: boolean; 13 | nonExplicitWhitelist?: boolean; 14 | cleanCode?: boolean; 15 | load?: 'currentOnly' | 'languageOnly'; 16 | nonExplicitSupportedLngs?: string[]; 17 | } 18 | 19 | export class LanguageUtil { 20 | private readonly supportedLngs: LanguageUtilsOptions['supportedLngs']; 21 | 22 | constructor(private options: LanguageUtilsOptions = {}) { 23 | this.options = options; 24 | 25 | this.supportedLngs = this.options.supportedLngs || false; 26 | } 27 | 28 | getScriptPartFromCode(code: string | null): string | null { 29 | if (!code || code.indexOf('-') < 0) return null; 30 | 31 | const p = code.split('-'); 32 | if (p.length === 2) return null; 33 | p.pop(); 34 | if (p[p.length - 1].toLowerCase() === 'x') return null; 35 | return this.formatLanguageCode(p.join('-')); 36 | } 37 | 38 | getLanguagePartFromCode(code: string) { 39 | if (!code || code.indexOf('-') < 0) return code; 40 | 41 | const p = code.split('-'); 42 | return this.formatLanguageCode(p[0]); 43 | } 44 | 45 | formatLanguageCode(code: string) { 46 | // http://www.iana.org/assignments/language-tags/language-tags.xhtml 47 | if (typeof code === 'string' && code.indexOf('-') > -1) { 48 | const specialCases = ['hans', 'hant', 'latn', 'cyrl', 'cans', 'mong', 'arab']; 49 | let p = code.split('-'); 50 | 51 | if (this.options.lowerCaseLng) { 52 | p = p.map((part) => part.toLowerCase()); 53 | } else if (p.length === 2) { 54 | p[0] = p[0].toLowerCase(); 55 | p[1] = p[1].toUpperCase(); 56 | 57 | if (specialCases.indexOf(p[1].toLowerCase()) > -1) p[1] = capitalize(p[1].toLowerCase()); 58 | } else if (p.length === 3) { 59 | p[0] = p[0].toLowerCase(); 60 | 61 | // if length 2 guess it's a country 62 | if (p[1].length === 2) p[1] = p[1].toUpperCase(); 63 | if (p[0] !== 'sgn' && p[2].length === 2) p[2] = p[2].toUpperCase(); 64 | 65 | if (specialCases.indexOf(p[1].toLowerCase()) > -1) p[1] = capitalize(p[1].toLowerCase()); 66 | if (specialCases.indexOf(p[2].toLowerCase()) > -1) p[2] = capitalize(p[2].toLowerCase()); 67 | } 68 | 69 | return p.join('-'); 70 | } 71 | 72 | return this.options.cleanCode || this.options.lowerCaseLng ? code.toLowerCase() : code; 73 | } 74 | 75 | isSupportedCode(code: string) { 76 | if (this.options.load === 'languageOnly' || this.options.nonExplicitSupportedLngs) { 77 | code = this.getLanguagePartFromCode(code); 78 | } 79 | return ( 80 | !this.supportedLngs || !this.supportedLngs.length || this.supportedLngs.indexOf(code) > -1 81 | ); 82 | } 83 | 84 | getFallbackCodes(fallbacks: any, code: string | null): string[] { 85 | if (!fallbacks) return []; 86 | if (typeof fallbacks === 'function') fallbacks = fallbacks(code); 87 | if (typeof fallbacks === 'string') fallbacks = [fallbacks]; 88 | if (Object.prototype.toString.apply(fallbacks) === '[object Array]') return fallbacks; 89 | 90 | if (!code) return fallbacks.default || []; 91 | 92 | // assume we have an object defining fallbacks 93 | let found = fallbacks[code]; 94 | if (!found) found = fallbacks[this.getScriptPartFromCode(code) as any]; 95 | if (!found) found = fallbacks[this.formatLanguageCode(code)]; 96 | if (!found) found = fallbacks[this.getLanguagePartFromCode(code)]; 97 | if (!found) found = fallbacks.default; 98 | 99 | return found || []; 100 | } 101 | 102 | toResolveHierarchy(code: string | null, fallbackCode: string) { 103 | const fallbackCodes = this.getFallbackCodes( 104 | fallbackCode || this.options.fallbackLng || [], 105 | code 106 | ); 107 | 108 | const codes: string[] = []; 109 | const addCode = (c: string | null) => { 110 | if (!c) return; 111 | if (this.isSupportedCode(c)) { 112 | codes.push(c); 113 | } else { 114 | // 115 | } 116 | }; 117 | 118 | if (typeof code === 'string' && code.indexOf('-') > -1) { 119 | if (this.options.load !== 'languageOnly') addCode(this.formatLanguageCode(code)); 120 | if (this.options.load !== 'languageOnly' && this.options.load !== 'currentOnly') 121 | addCode(this.getScriptPartFromCode(code)); 122 | if (this.options.load !== 'currentOnly') addCode(this.getLanguagePartFromCode(code)); 123 | } else if (typeof code === 'string') { 124 | addCode(this.formatLanguageCode(code)); 125 | } 126 | 127 | fallbackCodes.forEach((fc: any) => { 128 | if (codes.indexOf(fc) < 0) addCode(this.formatLanguageCode(fc)); 129 | }); 130 | 131 | return codes; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /test/syncJson/plurals-v3.test.ts: -------------------------------------------------------------------------------- 1 | import { PluralResolver } from '../../src/i18next/PluralResolver'; 2 | import { syncJson } from '../../src/syncJson'; 3 | 4 | describe('syncJson: plurals v3', () => { 5 | const pluralResolver = new PluralResolver(); 6 | 7 | it('should add plural form', () => { 8 | const source = { 9 | data: { book: 'book en', book_plural: 'books en' }, 10 | language: 'en', 11 | }; 12 | 13 | const actual = syncJson({ source, target: { data: {}, language: 'he' }, pluralResolver }); 14 | 15 | expect(actual.data).toStrictEqual({ 16 | book: 'book en', 17 | book_0: 'books en', 18 | book_1: 'books en', 19 | book_2: 'books en', 20 | book_3: 'books en', 21 | }); 22 | }); 23 | 24 | it('should add plural form with empty string as a value', () => { 25 | const source = { 26 | data: { book: 'book en', book_plural: 'books en' }, 27 | language: 'en', 28 | }; 29 | const actual = syncJson({ 30 | source, 31 | target: { data: { book_0: 'book he 0' }, language: 'he' }, 32 | pluralResolver, 33 | useEmptyString: true, 34 | }); 35 | 36 | expect(actual.data).toStrictEqual({ 37 | book: '', 38 | book_0: 'book he 0', 39 | book_1: '', 40 | book_2: '', 41 | book_3: '', 42 | }); 43 | }); 44 | 45 | it('should add plural form only if the target language needs it', () => { 46 | const source = { 47 | data: { book: 'book en', book_plural: 'books en' }, 48 | language: 'en', 49 | }; 50 | const actual = syncJson({ source, target: { data: {}, language: 'ja' }, pluralResolver }); 51 | 52 | expect(actual.data).toStrictEqual({ book: 'book en' }); 53 | }); 54 | 55 | it("should add keep base form when source doesn't have it and target language doesn't supports plural forms", () => { 56 | const source = { 57 | data: { book_plural: 'books en' }, 58 | language: 'en', 59 | }; 60 | const actual = syncJson({ 61 | source, 62 | target: { data: { book: 'book ja', book_plural: 'book other ja' }, language: 'ja' }, 63 | pluralResolver, 64 | }); 65 | 66 | expect(actual.data).toStrictEqual({ book: 'book ja' }); 67 | }); 68 | 69 | it('should not override existing plural forms', () => { 70 | const source = { 71 | data: { book: 'book en', book_plural: 'books en' }, 72 | language: 'en', 73 | }; 74 | const actual = syncJson({ 75 | source, 76 | target: { data: { book_plural: 'books de' }, language: 'de' }, 77 | pluralResolver, 78 | }); 79 | 80 | expect(actual.data).toStrictEqual({ 81 | book: 'book en', 82 | book_plural: 'books de', 83 | }); 84 | }); 85 | 86 | it('should not override existing values', () => { 87 | const source = { 88 | data: { book: 'book en', book_plural: 'books en' }, 89 | language: 'en', 90 | }; 91 | const actual = syncJson({ 92 | source, 93 | target: { 94 | data: { book: 'book de', book_plural: 'books de' }, 95 | language: 'de', 96 | }, 97 | pluralResolver, 98 | }); 99 | 100 | expect(actual.data).toStrictEqual({ 101 | book: 'book de', 102 | book_plural: 'books de', 103 | }); 104 | }); 105 | 106 | it('should not override existing values', () => { 107 | const source = { 108 | data: { book: 'book en', book_plural: 'books en' }, 109 | language: 'en', 110 | }; 111 | const actual = syncJson({ 112 | source, 113 | target: { 114 | data: { book: 'book de' }, 115 | language: 'de', 116 | }, 117 | pluralResolver, 118 | useEmptyString: true, 119 | }); 120 | 121 | expect(actual.data).toStrictEqual({ 122 | book: 'book de', 123 | book_plural: '', 124 | }); 125 | }); 126 | 127 | it('should handle plural of none standard plural form as a source (he)', () => { 128 | const source = { 129 | data: { 130 | book: 'book he', 131 | book_0: 'books 0 he', 132 | book_1: 'books 1 he', 133 | book_2: 'books 2 he', 134 | book_3: 'books 3 he', 135 | }, 136 | language: 'he', 137 | }; 138 | const actual = syncJson({ source, target: { data: {}, language: 'en' }, pluralResolver }); 139 | 140 | expect(actual.data).toStrictEqual({ book: 'book he', book_plural: 'books 3 he' }); 141 | }); 142 | 143 | it("should not remove plurals when the source lang doesn't have plural form", () => { 144 | const source = { 145 | data: { 146 | book: 'book ja', 147 | }, 148 | language: 'ja', 149 | }; 150 | const actual = syncJson({ 151 | source, 152 | target: { 153 | data: { 154 | book: 'book en', 155 | book_plural: 'books en', 156 | }, 157 | language: 'en', 158 | }, 159 | pluralResolver, 160 | }); 161 | 162 | expect(actual.data).toStrictEqual({ 163 | book: 'book en', 164 | book_plural: 'books en', 165 | }); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /test/syncLocales.test.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryJSON, vol } from 'memfs'; 2 | import { relative, resolve } from 'path'; 3 | import { syncLocales } from '../src'; 4 | 5 | function makePathNoneUnique(fileSystemList: DirectoryJSON) { 6 | return Object.keys(fileSystemList).reduce((result, filePath) => { 7 | const noneUniquePath = relative(resolve('.'), filePath).replace(/[\\]/g, '/'); 8 | result[noneUniquePath] = fileSystemList[filePath]; 9 | return result; 10 | }, {} as any); 11 | } 12 | 13 | describe('syncLocales - E2E', () => { 14 | it('should sync locale files without namespaces', () => { 15 | const primaryLanguage = 'en'; 16 | const otherLanguages = ['ja', 'he', 'de']; 17 | const outputFolder = resolve('./test/output/fixture1'); 18 | 19 | syncLocales({ 20 | primaryLanguage, 21 | secondaryLanguages: otherLanguages, 22 | localesFolder: resolve('./test/fixtures/fixture1'), 23 | outputFolder, 24 | fileExtension: '.json', 25 | }); 26 | 27 | expect(makePathNoneUnique(vol.toJSON(outputFolder))).toMatchSnapshot(); 28 | }); 29 | 30 | it('should sync locale files with namespaces', () => { 31 | const primaryLanguage = 'en'; 32 | const otherLanguages = ['ja', 'he', 'de']; 33 | const outputFolder = resolve('./test/output/fixture2'); 34 | 35 | syncLocales({ 36 | primaryLanguage, 37 | secondaryLanguages: otherLanguages, 38 | localesFolder: resolve('./test/fixtures/fixture2'), 39 | outputFolder, 40 | fileExtension: '.json', 41 | }); 42 | 43 | expect(makePathNoneUnique(vol.toJSON(outputFolder))).toMatchSnapshot(); 44 | }); 45 | 46 | it('should sync locale files with nested namespaces', () => { 47 | const primaryLanguage = 'en'; 48 | const otherLanguages = ['ja', 'he', 'de']; 49 | const outputFolder = resolve('./test/output/fixture3'); 50 | 51 | syncLocales({ 52 | primaryLanguage, 53 | secondaryLanguages: otherLanguages, 54 | localesFolder: resolve('./test/fixtures/fixture3'), 55 | outputFolder, 56 | fileExtension: '.json', 57 | }); 58 | 59 | expect(makePathNoneUnique(vol.toJSON(outputFolder))).toMatchSnapshot(); 60 | }); 61 | 62 | it('should sync locales to the same locales folder', () => { 63 | const primaryLanguage = 'en'; 64 | const otherLanguages = ['ja', 'he', 'de']; 65 | const localesFolder = resolve('./test/fixtures/fixture3'); 66 | 67 | syncLocales({ 68 | primaryLanguage, 69 | secondaryLanguages: otherLanguages, 70 | localesFolder: localesFolder, 71 | outputFolder: localesFolder, 72 | fileExtension: '.json', 73 | }); 74 | 75 | expect(makePathNoneUnique(vol.toJSON(localesFolder))).toMatchSnapshot(); 76 | }); 77 | 78 | it('should sync locale files with different folder structure', () => { 79 | const primaryLanguage = 'en'; 80 | const otherLanguages = ['ja', 'he', 'de']; 81 | const outputFolder = resolve('./test/output/fixture4'); 82 | 83 | syncLocales({ 84 | primaryLanguage, 85 | secondaryLanguages: otherLanguages, 86 | localesFolder: resolve('./test/fixtures/fixture4'), 87 | outputFolder, 88 | fileExtension: '.json', 89 | }); 90 | 91 | expect(makePathNoneUnique(vol.toJSON(outputFolder))).toMatchSnapshot(); 92 | }); 93 | 94 | it('should sync locale files with custom spaces', () => { 95 | const primaryLanguage = 'en'; 96 | const otherLanguages = ['ja', 'he', 'de']; 97 | const outputFolder = resolve('./test/output/with-spaces'); 98 | 99 | syncLocales({ 100 | primaryLanguage, 101 | secondaryLanguages: otherLanguages, 102 | localesFolder: resolve('./test/fixtures/fixture5'), 103 | outputFolder, 104 | fileExtension: '.json', 105 | spaces: 4, 106 | }); 107 | 108 | expect(makePathNoneUnique(vol.toJSON(outputFolder))).toMatchSnapshot(); 109 | }); 110 | 111 | it('should sync locale files with deprecated compatibilityJSON', () => { 112 | const primaryLanguage = 'en'; 113 | const otherLanguages = ['ja', 'he', 'de']; 114 | const outputFolder = resolve('./test/output/fixture5'); 115 | 116 | syncLocales({ 117 | primaryLanguage, 118 | secondaryLanguages: otherLanguages, 119 | localesFolder: resolve('./test/fixtures/fixture5'), 120 | outputFolder, 121 | fileExtension: '.json', 122 | compatibilityJSON: 'v3', 123 | }); 124 | 125 | expect(makePathNoneUnique(vol.toJSON(outputFolder))).toMatchSnapshot(); 126 | }); 127 | 128 | it('should sync locale files when using override plural rules', () => { 129 | const primaryLanguage = 'he'; 130 | const otherLanguages = ['ar']; 131 | const outputFolder = resolve('./test/output/with-override-plural-rules'); 132 | 133 | syncLocales({ 134 | primaryLanguage, 135 | secondaryLanguages: otherLanguages, 136 | localesFolder: resolve('./test/fixtures/fixture8'), 137 | outputFolder, 138 | overridePluralRules: (pluralResolver) => { 139 | ['he', 'ar'].forEach((lng) => 140 | pluralResolver.addRule(lng, pluralResolver.getRule('en') as any) 141 | ); 142 | }, 143 | }); 144 | 145 | expect(makePathNoneUnique(vol.toJSON(outputFolder))).toMatchSnapshot(); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /test/syncJson/plurals.test.ts: -------------------------------------------------------------------------------- 1 | import { PluralResolver } from '../../src/i18next/PluralResolver'; 2 | import { syncJson } from '../../src/syncJson'; 3 | 4 | describe('syncJson: plurals', () => { 5 | const pluralResolver = new PluralResolver({ compatibilityJSON: 'v4' }); 6 | 7 | it('should add plural form', () => { 8 | const source = { 9 | data: { book: 'book en', book_one: 'book en', book_other: 'books en' }, 10 | language: 'en', 11 | }; 12 | 13 | const actual = syncJson({ source, target: { data: {}, language: 'he' }, pluralResolver }); 14 | 15 | expect(actual.data).toStrictEqual({ 16 | book: 'book en', 17 | book_one: 'book en', 18 | book_two: 'books en', 19 | book_other: 'books en', 20 | }); 21 | }); 22 | 23 | it('should add plural form with empty string as a value', () => { 24 | const source = { 25 | data: { book: 'book en', book_one: 'book en', book_other: 'books en' }, 26 | language: 'en', 27 | }; 28 | const actual = syncJson({ 29 | source, 30 | target: { data: { book_two: 'books he 0' }, language: 'he' }, 31 | pluralResolver, 32 | useEmptyString: true, 33 | }); 34 | 35 | expect(actual.data).toStrictEqual({ 36 | book: '', 37 | book_one: '', 38 | book_two: 'books he 0', 39 | book_other: '', 40 | }); 41 | }); 42 | 43 | it('should add plural form only if the target language needs it', () => { 44 | const source = { 45 | data: { book: 'book en', book_one: 'book en', book_other: 'books en' }, 46 | language: 'en', 47 | }; 48 | const actual = syncJson({ source, target: { data: {}, language: 'ja' }, pluralResolver }); 49 | 50 | expect(actual.data).toStrictEqual({ book: 'book en' }); 51 | }); 52 | 53 | it("should add keep base form when source doesn't have it and target language doesn't supports plural forms", () => { 54 | const source = { 55 | data: { book_one: 'book en', book_other: 'books en' }, 56 | language: 'en', 57 | }; 58 | const actual = syncJson({ 59 | source, 60 | target: { data: { book: 'book ja', book_other: 'book other ja' }, language: 'ja' }, 61 | pluralResolver, 62 | }); 63 | 64 | expect(actual.data).toStrictEqual({ book: 'book ja' }); 65 | }); 66 | 67 | it('should not override existing plural forms', () => { 68 | const source = { 69 | data: { book: 'book en', book_one: 'book en', book_other: 'books en' }, 70 | language: 'en', 71 | }; 72 | const actual = syncJson({ 73 | source, 74 | target: { data: { book_other: 'books de' }, language: 'de' }, 75 | pluralResolver, 76 | }); 77 | 78 | expect(actual.data).toStrictEqual({ 79 | book: 'book en', 80 | book_one: 'book en', 81 | book_other: 'books de', 82 | }); 83 | }); 84 | 85 | it('should not override existing values', () => { 86 | const source = { 87 | data: { book: 'book en', book_one: 'book en', book_other: 'books en' }, 88 | language: 'en', 89 | }; 90 | const actual = syncJson({ 91 | source, 92 | target: { 93 | data: { book: 'book de', book_one: 'book de', book_other: 'books de' }, 94 | language: 'de', 95 | }, 96 | pluralResolver, 97 | }); 98 | 99 | expect(actual.data).toStrictEqual({ 100 | book: 'book de', 101 | book_one: 'book de', 102 | book_other: 'books de', 103 | }); 104 | }); 105 | 106 | it('should not override existing values', () => { 107 | const source = { 108 | data: { book: 'book en', book_one: 'book en', book_other: 'books en' }, 109 | language: 'en', 110 | }; 111 | const actual = syncJson({ 112 | source, 113 | target: { 114 | data: { book: 'book de', book_one: 'book de' }, 115 | language: 'de', 116 | }, 117 | pluralResolver, 118 | useEmptyString: true, 119 | }); 120 | 121 | expect(actual.data).toStrictEqual({ 122 | book: 'book de', 123 | book_one: 'book de', 124 | book_other: '', 125 | }); 126 | }); 127 | 128 | it('should handle plural of none standard plural form as a source (he)', () => { 129 | const source = { 130 | data: { 131 | book: 'book he', 132 | book_one: 'book one he', 133 | book_two: 'books two he', 134 | book_other: 'books other he', 135 | }, 136 | language: 'he', 137 | }; 138 | const actual = syncJson({ source, target: { data: {}, language: 'en' }, pluralResolver }); 139 | 140 | expect(actual.data).toStrictEqual({ 141 | book: 'book he', 142 | book_one: 'book one he', 143 | book_other: 'books other he', 144 | }); 145 | }); 146 | 147 | it("should not remove plurals when the source lang doesn't have plural form", () => { 148 | const source = { 149 | data: { 150 | book: 'book ja', 151 | }, 152 | language: 'ja', 153 | }; 154 | const actual = syncJson({ 155 | source, 156 | target: { 157 | data: { 158 | book: 'book en', 159 | book_other: 'books en', 160 | }, 161 | language: 'en', 162 | }, 163 | pluralResolver, 164 | }); 165 | 166 | expect(actual.data).toStrictEqual({ 167 | book: 'book en', 168 | book_other: 'books en', 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /test/syncJson/basicKeys.test.ts: -------------------------------------------------------------------------------- 1 | import { PluralResolver } from '../../src/i18next/PluralResolver'; 2 | import { syncJson } from '../../src/syncJson'; 3 | 4 | describe('syncJson: basic keys', () => { 5 | const pluralResolver = new PluralResolver(); 6 | 7 | it('should throw exception when the json is too deep', () => { 8 | const source = { data: { foo: 'foo', nested: { bar: 'bar', array: [1, 2] } }, language: 'en' }; 9 | 10 | expect(() => 11 | syncJson({ source, target: { data: {}, language: 'he' }, depth: 1, pluralResolver }) 12 | ).toThrowError('given json with depth that'); 13 | }); 14 | 15 | describe('add', () => { 16 | it('should add missing keys in json', () => { 17 | const source = { 18 | data: { foo: 'foo', nested: { bar: 'bar', array: [1, 2] } }, 19 | language: 'en', 20 | }; 21 | const actual = syncJson({ source, target: { data: {}, language: 'he' }, pluralResolver }); 22 | 23 | expect(actual.data).toStrictEqual(source.data); 24 | expect(source).toStrictEqual(source); 25 | }); 26 | 27 | it('should not override existing values', () => { 28 | const source = { 29 | data: { foo: 'foo', nested: { bar: 'bar', array: [1, 2, 'string'] } }, 30 | language: 'en', 31 | }; 32 | const result = syncJson({ 33 | source, 34 | target: { 35 | data: { foo: 'original foo', nested: { array: [2] } }, 36 | language: 'he', 37 | }, 38 | pluralResolver, 39 | }); 40 | 41 | expect(result.data).toStrictEqual({ 42 | foo: 'original foo', 43 | nested: { bar: 'bar', array: [2, 2, 'string'] }, 44 | }); 45 | 46 | expect(source).toStrictEqual(source); 47 | }); 48 | 49 | it('should override existing value if types are different', () => { 50 | const source = { 51 | data: { foo: 'foo', nested: { bar: 'bar', array: [1, 2, 'string'] } }, 52 | language: 'en', 53 | }; 54 | const result = syncJson({ 55 | source, 56 | target: { 57 | data: { foo: ['original foo'], nested: { array: { '0': 1 } } }, 58 | language: 'he', 59 | }, 60 | pluralResolver, 61 | }); 62 | 63 | expect(result.data).toStrictEqual({ 64 | foo: 'foo', 65 | nested: { bar: 'bar', array: [1, 2, 'string'] }, 66 | }); 67 | 68 | expect(source).toStrictEqual(source); 69 | }); 70 | 71 | it('should keep values on same json structure', () => { 72 | const source = { 73 | data: { foo: 'foo', nested: { bar: 'bar', array: [1, 2, 'string'] } }, 74 | language: 'en', 75 | }; 76 | 77 | const result = syncJson({ 78 | source, 79 | target: { 80 | data: { foo: 'he foo', nested: { bar: 'he bar', array: ['he 1', 'he 2', 'he string'] } }, 81 | language: 'he', 82 | }, 83 | pluralResolver, 84 | }); 85 | 86 | expect(result.data).toStrictEqual({ 87 | foo: 'he foo', 88 | nested: { bar: 'he bar', array: ['he 1', 'he 2', 'he string'] }, 89 | }); 90 | }); 91 | }); 92 | 93 | describe('remove', () => { 94 | it('should remove redundant keys in json', () => { 95 | const source = { 96 | data: { foo: 'foo', nested: { bar: 'bar', array: [1, 2] } }, 97 | language: 'en', 98 | }; 99 | 100 | const result = syncJson({ 101 | source, 102 | target: { 103 | data: { 104 | foo: 'foo', 105 | outerBar: 'outer bar', 106 | nested: { bar: 'bar', nestedBar: 'nested bar', array: [3, 4, 5] }, 107 | }, 108 | language: 'he', 109 | }, 110 | pluralResolver, 111 | }); 112 | 113 | expect(result.data).toStrictEqual({ 114 | foo: 'foo', 115 | nested: { bar: 'bar', array: [3, 4] }, 116 | }); 117 | 118 | expect(source).toStrictEqual(source); 119 | }); 120 | }); 121 | 122 | describe('sort', () => { 123 | it("should sort target's keys by the source order", () => { 124 | const source = { 125 | data: { foo: 'foo', nested: { bar: 'bar', array: [1, 2] } }, 126 | language: 'en', 127 | }; 128 | const target = { 129 | data: { nested: { array: [3], bar: 't-bar' }, foo: 't-foo' }, 130 | language: 'he', 131 | }; 132 | 133 | const result = syncJson({ source, target, pluralResolver }); 134 | 135 | expect(result.data).toStrictEqual({ foo: 't-foo', nested: { bar: 't-bar', array: [3, 2] } }); 136 | 137 | expect(Object.keys(result.data)).toStrictEqual(Object.keys(source.data)); 138 | expect(Object.keys((result.data as any).nested)).toStrictEqual( 139 | Object.keys(source.data.nested) 140 | ); 141 | }); 142 | }); 143 | 144 | describe('useEmptyString = true', () => { 145 | it('should add missing keys in json and use empty string', () => { 146 | const source = { 147 | data: { foo: 'foo', nested: { bar: 'bar', array: [1, 2] } }, 148 | language: 'en', 149 | }; 150 | const actual = syncJson({ 151 | source, 152 | target: { data: { foo: 'he foo' }, language: 'he' }, 153 | pluralResolver, 154 | useEmptyString: true, 155 | }); 156 | 157 | expect(actual.data).toStrictEqual({ 158 | foo: 'he foo', 159 | nested: { bar: '', array: ['', ''] }, 160 | }); 161 | expect(source).toStrictEqual(source); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /test/generateLocaleFiles.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { generateLocaleFiles } from '../src/generateLocaleFiles'; 3 | 4 | describe('generateLocaleFiles', () => { 5 | it('should throw error when there is no files for the primary language', () => { 6 | expect(() => 7 | generateLocaleFiles({ 8 | primaryLanguage: 'ja', 9 | otherLanguages: ['en'], 10 | localesFolder: resolve('./test/fixtures/fixture1'), 11 | fileExtension: '.json', 12 | }) 13 | ).toThrowError('There are no files for your primary language'); 14 | }); 15 | 16 | it('should add missing language file structure', () => { 17 | const localeFiles = generateLocaleFiles({ 18 | primaryLanguage: 'en', 19 | otherLanguages: ['ja'], 20 | localesFolder: resolve('./test/fixtures/fixture1'), 21 | fileExtension: '.json', 22 | }); 23 | 24 | expect(localeFiles).toHaveProperty('ja', { 25 | '': { 26 | data: {}, 27 | hash: '', 28 | filePath: expect.stringMatching('fixtures/fixture1/ja.json'.replace(/[/]/g, '[\\\\/]+')), 29 | }, 30 | }); 31 | }); 32 | 33 | it('should add missing namespace file structure', () => { 34 | const localeFiles = generateLocaleFiles({ 35 | primaryLanguage: 'en', 36 | otherLanguages: ['de', 'ja'], 37 | localesFolder: resolve('./test/fixtures/fixture2'), 38 | fileExtension: '.json', 39 | }); 40 | 41 | expect(localeFiles).toHaveProperty('de', { 42 | common: { 43 | data: { test: 'bla-de', test_one: 'bla-de' }, 44 | hash: expect.any(String), 45 | filePath: expect.stringMatching( 46 | 'fixtures/fixture2/de/common.json'.replace(/[/]/g, '[\\\\/]+') 47 | ), 48 | }, 49 | front: { 50 | data: {}, 51 | hash: '', 52 | filePath: expect.stringMatching( 53 | 'fixtures/fixture2/de/front.json'.replace(/[/]/g, '[\\\\/]+') 54 | ), 55 | }, 56 | }); 57 | 58 | expect(localeFiles).toHaveProperty('ja', { 59 | common: { 60 | data: {}, 61 | hash: '', 62 | filePath: expect.stringMatching( 63 | 'fixtures/fixture2/ja/common.json'.replace(/[/]/g, '[\\\\/]+') 64 | ), 65 | }, 66 | front: { 67 | data: {}, 68 | hash: '', 69 | filePath: expect.stringMatching( 70 | 'fixtures/fixture2/ja/front.json'.replace(/[/]/g, '[\\\\/]+') 71 | ), 72 | }, 73 | }); 74 | }); 75 | 76 | it('should support nested namespaces', () => { 77 | const localeFiles = generateLocaleFiles({ 78 | primaryLanguage: 'en', 79 | otherLanguages: ['de'], 80 | localesFolder: resolve('./test/fixtures/fixture3'), 81 | fileExtension: '.json', 82 | }); 83 | 84 | expect(localeFiles).toHaveProperty('en', { 85 | common: { 86 | data: { test: 'bla en', test_one: 'bla one en', test_other: 'bla other en' }, 87 | hash: expect.any(String), 88 | filePath: expect.stringMatching( 89 | 'fixtures/fixture3/en/common.json'.replace(/[/]/g, '[\\\\/]+') 90 | ), 91 | }, 92 | 'nested/a': { 93 | data: { a: 'bla-en' }, 94 | hash: expect.any(String), 95 | filePath: expect.stringMatching( 96 | 'fixtures/fixture3/en/nested/a.json'.replace(/[/]/g, '[\\\\/]+') 97 | ), 98 | }, 99 | 'nested/b': { 100 | data: { b: 'bla-en' }, 101 | hash: expect.any(String), 102 | filePath: expect.stringMatching( 103 | 'fixtures/fixture3/en/nested/b.json'.replace(/[/]/g, '[\\\\/]+') 104 | ), 105 | }, 106 | }); 107 | 108 | expect(localeFiles).toHaveProperty('de', { 109 | common: { 110 | data: { test: 'bla-de' }, 111 | hash: expect.any(String), 112 | filePath: expect.stringMatching( 113 | 'fixtures/fixture3/de/common.json'.replace(/[/]/g, '[\\\\/]+') 114 | ), 115 | }, 116 | 'nested/a': { 117 | data: { a: 'bla-de' }, 118 | hash: expect.any(String), 119 | filePath: expect.stringMatching( 120 | 'fixtures/fixture3/de/nested/a.json'.replace(/[/]/g, '[\\\\/]+') 121 | ), 122 | }, 123 | 'nested/b': { 124 | data: {}, 125 | hash: expect.any(String), 126 | filePath: expect.stringMatching( 127 | 'fixtures/fixture3/de/nested/b.json'.replace(/[/]/g, '[\\\\/]+') 128 | ), 129 | }, 130 | }); 131 | }); 132 | 133 | it('should support {namespace}/{language} folder structure', () => { 134 | const localeFiles = generateLocaleFiles({ 135 | primaryLanguage: 'en', 136 | otherLanguages: ['he'], 137 | localesFolder: resolve('./test/fixtures/fixture4'), 138 | fileExtension: '.json', 139 | }); 140 | 141 | expect(localeFiles).toHaveProperty('he', { 142 | common: { 143 | data: { test: 'bla-he', test_many: 'bla-many' }, 144 | hash: expect.any(String), 145 | filePath: expect.stringMatching( 146 | 'fixtures/fixture4/common/he.json'.replace(/[/]/g, '[\\\\/]+') 147 | ), 148 | }, 149 | front: { 150 | data: { test: 'bla', test_few: 'bla-few' }, 151 | hash: expect.any(String), 152 | filePath: expect.stringMatching( 153 | 'fixtures/fixture4/front/he.json'.replace(/[/]/g, '[\\\\/]+') 154 | ), 155 | }, 156 | }); 157 | }); 158 | 159 | it('should support {namespace}/{language} folder structure when namespace starts with lang', () => { 160 | const localeFiles = generateLocaleFiles({ 161 | primaryLanguage: 'en', 162 | otherLanguages: ['he'], 163 | localesFolder: resolve('./test/fixtures/fixture6'), 164 | fileExtension: '.json', 165 | }); 166 | 167 | expect(localeFiles).toHaveProperty('he', { 168 | common: { 169 | data: { test: 'bla-he', test_many: 'bla-many' }, 170 | hash: expect.any(String), 171 | filePath: expect.stringMatching( 172 | 'fixtures/fixture6/common/he.json'.replace(/[/]/g, '[\\\\/]+') 173 | ), 174 | }, 175 | hebrew: { 176 | data: { test: 'bla', test_few: 'bla-few' }, 177 | hash: expect.any(String), 178 | filePath: expect.stringMatching( 179 | 'fixtures/fixture6/hebrew/he.json'.replace(/[/]/g, '[\\\\/]+') 180 | ), 181 | }, 182 | }); 183 | }); 184 | 185 | it('should support full locale code', () => { 186 | const localeFiles = generateLocaleFiles({ 187 | primaryLanguage: 'en-uk', 188 | otherLanguages: ['de-at'], 189 | localesFolder: resolve('./test/fixtures/fixture7'), 190 | fileExtension: '.json', 191 | }); 192 | 193 | expect(localeFiles).toHaveProperty('de-at', { 194 | common: { 195 | data: { test: 'bla-de', test_one: 'bla-de' }, 196 | hash: expect.any(String), 197 | filePath: expect.stringMatching( 198 | 'fixtures/fixture7/de-at/common.json'.replace(/[/]/g, '[\\\\/]+') 199 | ), 200 | }, 201 | front: { 202 | data: {}, 203 | hash: expect.any(String), 204 | filePath: expect.stringMatching( 205 | 'fixtures/fixture7/de-at/front.json'.replace(/[/]/g, '[\\\\/]+') 206 | ), 207 | }, 208 | }); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 6 | 7 | #### [2.1.1](https://github.com/felixmosh/i18next-locales-sync/compare/2.1.0...2.1.1) 8 | 9 | - fix: keep base form of in languages that doesn't need plural & source doesn't have the base, closes #40 [`#40`](https://github.com/felixmosh/i18next-locales-sync/issues/40) 10 | - Deps bump [`9e96437`](https://github.com/felixmosh/i18next-locales-sync/commit/9e964378f8bb27b31c3b066e7331dd612404d964) 11 | - Update main.yml [`a7a12ce`](https://github.com/felixmosh/i18next-locales-sync/commit/a7a12cee5a24f807a6abcf707b93a50ae79c3f4c) 12 | - fix: remove glob types [`1569741`](https://github.com/felixmosh/i18next-locales-sync/commit/1569741bdcd03d6ab680010e89b89247a77e33d3) 13 | 14 | #### [2.1.0](https://github.com/felixmosh/i18next-locales-sync/compare/2.0.1...2.1.0) 15 | 16 | > 4 July 2024 17 | 18 | - Bump deps [`d8fec60`](https://github.com/felixmosh/i18next-locales-sync/commit/d8fec6062468145fc2679e821858f444c725a290) 19 | - Bump @babel/traverse from 7.15.0 to 7.23.2 [`beae5cd`](https://github.com/felixmosh/i18next-locales-sync/commit/beae5cd48c324c0e5bb928a11294997cf58b536f) 20 | - switch to fdir and picomatch [`41a8b68`](https://github.com/felixmosh/i18next-locales-sync/commit/41a8b686812f3c30f49ae7b49999ffa07ac1649a) 21 | 22 | #### [2.0.1](https://github.com/felixmosh/i18next-locales-sync/compare/2.0.0...2.0.1) 23 | 24 | > 10 August 2022 25 | 26 | - fix: stop confusing namespace with a language prefix [`404ccda`](https://github.com/felixmosh/i18next-locales-sync/commit/404ccdad5174f5630108df478b7fb4656de880bf) 27 | - Bump parse-url from 6.0.0 to 6.0.2 [`cb4ba7a`](https://github.com/felixmosh/i18next-locales-sync/commit/cb4ba7ac7984f04c6359da80adfc31b94c0a1119) 28 | - Update example [`84c0fab`](https://github.com/felixmosh/i18next-locales-sync/commit/84c0fabf7ef67626984b1cb9a9fdfd7e46e1292d) 29 | 30 | ### [2.0.0](https://github.com/felixmosh/i18next-locales-sync/compare/1.2.1...2.0.0) 31 | 32 | > 4 July 2022 33 | 34 | - Add support for JSON v4 plurals [`b6109fd`](https://github.com/felixmosh/i18next-locales-sync/commit/b6109fd3ed5c82293d693e37186a67532173e6e5) 35 | - Add support for JSON v4 plurals [`ab92836`](https://github.com/felixmosh/i18next-locales-sync/commit/ab928369e6e048f9c58efbbfe6746a3c6836e662) 36 | - Bump deps [`e0eb842`](https://github.com/felixmosh/i18next-locales-sync/commit/e0eb842e0aad6011e373240c4c2891dbb032079e) 37 | 38 | #### [1.2.1](https://github.com/felixmosh/i18next-locales-sync/compare/1.2.0...1.2.1) 39 | 40 | > 24 June 2022 41 | 42 | - fix: yargs doesn't support aliases with 2 chars, https://github.com/yargs/yargs/issues/311 [`#23`](https://github.com/felixmosh/i18next-locales-sync/issues/23) 43 | - Release 1.2.1 [`5fa1db4`](https://github.com/felixmosh/i18next-locales-sync/commit/5fa1db42f8b6141d566a32d278fce3fd6b2cfc87) 44 | 45 | #### [1.2.0](https://github.com/felixmosh/i18next-locales-sync/compare/1.1.2...1.2.0) 46 | 47 | > 18 June 2022 48 | 49 | - Add support for custom spacing in resulting json files [`1d6d355`](https://github.com/felixmosh/i18next-locales-sync/commit/1d6d355097ac8eaa4a987dfe4393738412c0c762) 50 | - Fix minor styling issues [`0ed741d`](https://github.com/felixmosh/i18next-locales-sync/commit/0ed741dbc3f5aa9540041c87c131392f6354cd56) 51 | - Bump shelljs from 0.8.4 to 0.8.5 [`78666ae`](https://github.com/felixmosh/i18next-locales-sync/commit/78666ae6452482c58e42c4155e0bc220c32c4a58) 52 | 53 | #### [1.1.2](https://github.com/felixmosh/i18next-locales-sync/compare/1.1.1...1.1.2) 54 | 55 | > 28 April 2022 56 | 57 | - Deps bump [`e90d2dd`](https://github.com/felixmosh/i18next-locales-sync/commit/e90d2dd4e0f6ce847fbffc05491db1fee321e6e8) 58 | - Bump node-fetch from 2.6.1 to 2.6.7 [`07a7003`](https://github.com/felixmosh/i18next-locales-sync/commit/07a70032b4fe85732b04733069f85397c87f80c0) 59 | - Release 1.1.2 [`ee45f84`](https://github.com/felixmosh/i18next-locales-sync/commit/ee45f84df826a8f5d62cf613f9d1b1ab8a73764c) 60 | 61 | #### [1.1.1](https://github.com/felixmosh/i18next-locales-sync/compare/1.1.0...1.1.1) 62 | 63 | > 26 August 2021 64 | 65 | - Make the lib run with npx [`ccc7a25`](https://github.com/felixmosh/i18next-locales-sync/commit/ccc7a25ab0b0f097aa0a06470b5413e24b2adc69) 66 | - Bump glob-parent from 5.1.1 to 5.1.2 [`dc300cc`](https://github.com/felixmosh/i18next-locales-sync/commit/dc300cc0cdc027d9146c63f78c5b9418fa44285d) 67 | - Release 1.1.1 [`8153b26`](https://github.com/felixmosh/i18next-locales-sync/commit/8153b26cba9f5293e223f638e7d0ddd06144378d) 68 | 69 | #### [1.1.0](https://github.com/felixmosh/i18next-locales-sync/compare/1.0.4...1.1.0) 70 | 71 | > 1 June 2021 72 | 73 | - Bump handlebars from 4.7.6 to 4.7.7 [`#2`](https://github.com/felixmosh/i18next-locales-sync/pull/2) 74 | - Deps bump [`8f07edd`](https://github.com/felixmosh/i18next-locales-sync/commit/8f07eddd378bb0c7b3b17121d490d1f19a6f7dc3) 75 | - feat: add useEmptyString option to generate empty string as a value for new keys [`22ce291`](https://github.com/felixmosh/i18next-locales-sync/commit/22ce2914b4492821d3a99b1155241673e0872e63) 76 | - Bump browserslist from 4.14.5 to 4.16.6 [`e5e345c`](https://github.com/felixmosh/i18next-locales-sync/commit/e5e345ccd74e3b0868e076d28d6c5faad9ca53ae) 77 | 78 | #### [1.0.4](https://github.com/felixmosh/i18next-locales-sync/compare/1.0.3...1.0.4) 79 | 80 | > 2 May 2021 81 | 82 | - Release 1.0.4 [`d53d30f`](https://github.com/felixmosh/i18next-locales-sync/commit/d53d30f54f6ab80d10364a8e6d13f3ad01905094) 83 | - Update README.md [`cba7574`](https://github.com/felixmosh/i18next-locales-sync/commit/cba757403af532e57ff082d16487368f7f653022) 84 | 85 | #### [1.0.3](https://github.com/felixmosh/i18next-locales-sync/compare/1.0.2...1.0.3) 86 | 87 | > 12 February 2021 88 | 89 | - Release 1.0.3 [`8b95cf8`](https://github.com/felixmosh/i18next-locales-sync/commit/8b95cf840673e08616073c3e0e50f29c5ca2423d) 90 | - Add home page links [`07575d7`](https://github.com/felixmosh/i18next-locales-sync/commit/07575d76e5107bf6a905cb9473bf2382423fda57) 91 | 92 | #### [1.0.2](https://github.com/felixmosh/i18next-locales-sync/compare/1.0.1...1.0.2) 93 | 94 | > 12 February 2021 95 | 96 | - Add release-it config [`e19c8bc`](https://github.com/felixmosh/i18next-locales-sync/commit/e19c8bca48a71e941a14e286800ecb6cabcff7a5) 97 | - Release 1.0.2 [`750a712`](https://github.com/felixmosh/i18next-locales-sync/commit/750a712effbfbbc5d7ea11b7c0d88dff14c5e5f6) 98 | 99 | #### [1.0.1](https://github.com/felixmosh/i18next-locales-sync/compare/1.0.0...1.0.1) 100 | 101 | > 11 February 2021 102 | 103 | - Release 1.0.1 [`22e653c`](https://github.com/felixmosh/i18next-locales-sync/commit/22e653c1404c73b47c944a334689a1cb71ce4c85) 104 | - Add keywords [`056ca0f`](https://github.com/felixmosh/i18next-locales-sync/commit/056ca0f3fe3d8dbff54b610d3582be4d6c57f266) 105 | 106 | #### 1.0.0 107 | 108 | > 11 February 2021 109 | 110 | - Initial commit [`eea2249`](https://github.com/felixmosh/i18next-locales-sync/commit/eea224998c4ea862f16f35fd333513f95aeefebb) 111 | - Add cli [`e5611a4`](https://github.com/felixmosh/i18next-locales-sync/commit/e5611a45caee4c56fdbca5c38f7c446f8acdfc14) 112 | - Add release-it & auto changelog [`0a95ffe`](https://github.com/felixmosh/i18next-locales-sync/commit/0a95ffe2fecdba6748e6b19090ff9be8c23c869b) 113 | -------------------------------------------------------------------------------- /test/__snapshots__/syncLocales.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`syncLocales - E2E should sync locale files when using override plural rules 1`] = ` 4 | Object { 5 | "test/output/with-override-plural-rules/ar/common.json": "{ 6 | \\"test\\": \\"bla-ar\\", 7 | \\"test_plural\\": \\"bla-ar-plural\\" 8 | }", 9 | "test/output/with-override-plural-rules/he/common.json": "{ 10 | \\"test\\": \\"bla-he\\", 11 | \\"test_plural\\": \\"bla-he-plural\\" 12 | }", 13 | } 14 | `; 15 | 16 | exports[`syncLocales - E2E should sync locale files with custom spaces 1`] = ` 17 | Object { 18 | "test/output/with-spaces/common/de.json": "{ 19 | \\"test\\": \\"bla-en\\", 20 | \\"test_plural\\": \\"bla-plural-en\\" 21 | }", 22 | "test/output/with-spaces/common/en.json": "{ 23 | \\"test\\": \\"bla-en\\", 24 | \\"test_plural\\": \\"bla-plural-en\\" 25 | }", 26 | "test/output/with-spaces/common/he.json": "{ 27 | \\"test\\": \\"bla-he\\", 28 | \\"test_plural\\": \\"bla-plural-en\\" 29 | }", 30 | "test/output/with-spaces/common/ja.json": "{ 31 | \\"test\\": \\"bla-en\\", 32 | \\"test_plural\\": \\"bla-plural-en\\" 33 | }", 34 | "test/output/with-spaces/front/de.json": "{ 35 | \\"test\\": \\"bla-en\\" 36 | }", 37 | "test/output/with-spaces/front/en.json": "{ 38 | \\"test\\": \\"bla-en\\" 39 | }", 40 | "test/output/with-spaces/front/he.json": "{ 41 | \\"test\\": \\"bla\\" 42 | }", 43 | "test/output/with-spaces/front/ja.json": "{ 44 | \\"test\\": \\"bla-en\\" 45 | }", 46 | } 47 | `; 48 | 49 | exports[`syncLocales - E2E should sync locale files with deprecated compatibilityJSON 1`] = ` 50 | Object { 51 | "test/output/fixture5/common/de.json": "{ 52 | \\"test\\": \\"bla-en\\", 53 | \\"test_plural\\": \\"bla-plural-en\\" 54 | }", 55 | "test/output/fixture5/common/en.json": "{ 56 | \\"test\\": \\"bla-en\\", 57 | \\"test_plural\\": \\"bla-plural-en\\" 58 | }", 59 | "test/output/fixture5/common/he.json": "{ 60 | \\"test\\": \\"bla-he\\", 61 | \\"test_1\\": \\"bla-1-he\\", 62 | \\"test_0\\": \\"bla-plural-en\\", 63 | \\"test_2\\": \\"bla-plural-en\\", 64 | \\"test_3\\": \\"bla-plural-en\\" 65 | }", 66 | "test/output/fixture5/common/ja.json": "{ 67 | \\"test\\": \\"bla-en\\" 68 | }", 69 | "test/output/fixture5/front/de.json": "{ 70 | \\"test\\": \\"bla-en\\" 71 | }", 72 | "test/output/fixture5/front/en.json": "{ 73 | \\"test\\": \\"bla-en\\" 74 | }", 75 | "test/output/fixture5/front/he.json": "{ 76 | \\"test\\": \\"bla\\", 77 | \\"test_0\\": \\"bla-0\\" 78 | }", 79 | "test/output/fixture5/front/ja.json": "{ 80 | \\"test\\": \\"bla-en\\" 81 | }", 82 | } 83 | `; 84 | 85 | exports[`syncLocales - E2E should sync locale files with different folder structure 1`] = ` 86 | Object { 87 | "test/output/fixture4/common/de.json": "{ 88 | \\"test\\": \\"bla en\\", 89 | \\"test_one\\": \\"bla one en\\", 90 | \\"test_other\\": \\"bla other en\\" 91 | }", 92 | "test/output/fixture4/common/en.json": "{ 93 | \\"test\\": \\"bla en\\", 94 | \\"test_one\\": \\"bla one en\\", 95 | \\"test_other\\": \\"bla other en\\" 96 | }", 97 | "test/output/fixture4/common/he.json": "{ 98 | \\"test\\": \\"bla-he\\", 99 | \\"test_one\\": \\"bla one en\\", 100 | \\"test_two\\": \\"bla other en\\", 101 | \\"test_other\\": \\"bla other en\\" 102 | }", 103 | "test/output/fixture4/common/ja.json": "{ 104 | \\"test\\": \\"bla en\\" 105 | }", 106 | "test/output/fixture4/front/de.json": "{ 107 | \\"test\\": \\"bla-en\\" 108 | }", 109 | "test/output/fixture4/front/en.json": "{ 110 | \\"test\\": \\"bla-en\\" 111 | }", 112 | "test/output/fixture4/front/he.json": "{ 113 | \\"test\\": \\"bla\\" 114 | }", 115 | "test/output/fixture4/front/ja.json": "{ 116 | \\"test\\": \\"bla-en\\" 117 | }", 118 | } 119 | `; 120 | 121 | exports[`syncLocales - E2E should sync locale files with namespaces 1`] = ` 122 | Object { 123 | "test/output/fixture2/de/common.json": "{ 124 | \\"test\\": \\"bla-de\\", 125 | \\"test_one\\": \\"bla-de\\", 126 | \\"test_other\\": \\"bla other en\\" 127 | }", 128 | "test/output/fixture2/de/front.json": "{ 129 | \\"title\\": \\"front\\" 130 | }", 131 | "test/output/fixture2/en/common.json": "{ 132 | \\"test\\": \\"bla en\\", 133 | \\"test_one\\": \\"bla one en\\", 134 | \\"test_other\\": \\"bla other en\\" 135 | }", 136 | "test/output/fixture2/en/front.json": "{ 137 | \\"title\\": \\"front\\" 138 | }", 139 | "test/output/fixture2/he/common.json": "{ 140 | \\"test\\": \\"bla en\\", 141 | \\"test_one\\": \\"bla one en\\", 142 | \\"test_two\\": \\"bla other en\\", 143 | \\"test_other\\": \\"bla other en\\" 144 | }", 145 | "test/output/fixture2/he/front.json": "{ 146 | \\"title\\": \\"front\\" 147 | }", 148 | "test/output/fixture2/ja/common.json": "{ 149 | \\"test\\": \\"bla en\\" 150 | }", 151 | "test/output/fixture2/ja/front.json": "{ 152 | \\"title\\": \\"front\\" 153 | }", 154 | } 155 | `; 156 | 157 | exports[`syncLocales - E2E should sync locale files with nested namespaces 1`] = ` 158 | Object { 159 | "test/output/fixture3/de/common.json": "{ 160 | \\"test\\": \\"bla-de\\", 161 | \\"test_one\\": \\"bla one en\\", 162 | \\"test_other\\": \\"bla other en\\" 163 | }", 164 | "test/output/fixture3/de/nested/a.json": "{ 165 | \\"a\\": \\"bla-de\\" 166 | }", 167 | "test/output/fixture3/de/nested/b.json": "{ 168 | \\"b\\": \\"bla-en\\" 169 | }", 170 | "test/output/fixture3/en/common.json": "{ 171 | \\"test\\": \\"bla en\\", 172 | \\"test_one\\": \\"bla one en\\", 173 | \\"test_other\\": \\"bla other en\\" 174 | }", 175 | "test/output/fixture3/en/nested/a.json": "{ 176 | \\"a\\": \\"bla-en\\" 177 | }", 178 | "test/output/fixture3/en/nested/b.json": "{ 179 | \\"b\\": \\"bla-en\\" 180 | }", 181 | "test/output/fixture3/he/common.json": "{ 182 | \\"test\\": \\"bla-he\\", 183 | \\"test_two\\": \\"bla-two-he\\", 184 | \\"test_one\\": \\"bla one en\\", 185 | \\"test_other\\": \\"bla other en\\" 186 | }", 187 | "test/output/fixture3/he/nested/a.json": "{ 188 | \\"a\\": \\"bla-en\\" 189 | }", 190 | "test/output/fixture3/he/nested/b.json": "{ 191 | \\"b\\": \\"bla-en\\" 192 | }", 193 | "test/output/fixture3/ja/common.json": "{ 194 | \\"test\\": \\"bla en\\" 195 | }", 196 | "test/output/fixture3/ja/nested/a.json": "{ 197 | \\"a\\": \\"bla-en\\" 198 | }", 199 | "test/output/fixture3/ja/nested/b.json": "{ 200 | \\"b\\": \\"bla-en\\" 201 | }", 202 | } 203 | `; 204 | 205 | exports[`syncLocales - E2E should sync locale files without namespaces 1`] = ` 206 | Object { 207 | "test/output/fixture1/de.json": "{ 208 | \\"test\\": \\"bla en\\", 209 | \\"test_one\\": \\"bla one en\\", 210 | \\"test_other\\": \\"bla other en\\" 211 | }", 212 | "test/output/fixture1/en.json": "{ 213 | \\"test\\": \\"bla en\\", 214 | \\"test_one\\": \\"bla one en\\", 215 | \\"test_other\\": \\"bla other en\\" 216 | }", 217 | "test/output/fixture1/he.json": "{ 218 | \\"test\\": \\"bla\\", 219 | \\"test_one\\": \\"bla-0\\", 220 | \\"test_two\\": \\"bla other en\\", 221 | \\"test_other\\": \\"bla other en\\" 222 | }", 223 | "test/output/fixture1/ja.json": "{ 224 | \\"test\\": \\"bla en\\" 225 | }", 226 | } 227 | `; 228 | 229 | exports[`syncLocales - E2E should sync locales to the same locales folder 1`] = ` 230 | Object { 231 | "test/fixtures/fixture3/de/common.json": "{ 232 | \\"test\\": \\"bla-de\\", 233 | \\"test_one\\": \\"bla one en\\", 234 | \\"test_other\\": \\"bla other en\\" 235 | }", 236 | "test/fixtures/fixture3/de/nested/a.json": "{ 237 | \\"a\\": \\"bla-de\\" 238 | } 239 | ", 240 | "test/fixtures/fixture3/de/nested/b.json": "{ 241 | \\"b\\": \\"bla-en\\" 242 | }", 243 | "test/fixtures/fixture3/en/common.json": "{ 244 | \\"test\\": \\"bla en\\", 245 | \\"test_one\\": \\"bla one en\\", 246 | \\"test_other\\": \\"bla other en\\" 247 | } 248 | ", 249 | "test/fixtures/fixture3/en/nested/a.json": "{ 250 | \\"a\\": \\"bla-en\\" 251 | } 252 | ", 253 | "test/fixtures/fixture3/en/nested/b.json": "{ 254 | \\"b\\": \\"bla-en\\" 255 | } 256 | ", 257 | "test/fixtures/fixture3/he/common.json": "{ 258 | \\"test\\": \\"bla-he\\", 259 | \\"test_two\\": \\"bla-two-he\\", 260 | \\"test_one\\": \\"bla one en\\", 261 | \\"test_other\\": \\"bla other en\\" 262 | }", 263 | "test/fixtures/fixture3/he/nested/a.json": "{ 264 | \\"a\\": \\"bla-en\\" 265 | }", 266 | "test/fixtures/fixture3/he/nested/b.json": "{ 267 | \\"b\\": \\"bla-en\\" 268 | }", 269 | "test/fixtures/fixture3/ja/common.json": "{ 270 | \\"test\\": \\"bla en\\" 271 | }", 272 | "test/fixtures/fixture3/ja/nested/a.json": "{ 273 | \\"a\\": \\"bla-en\\" 274 | }", 275 | "test/fixtures/fixture3/ja/nested/b.json": "{ 276 | \\"b\\": \\"bla-en\\" 277 | }", 278 | } 279 | `; 280 | -------------------------------------------------------------------------------- /src/i18next/PluralResolver.ts: -------------------------------------------------------------------------------- 1 | import { CompatibilityJSON } from '../../types/types'; 2 | import { LanguageUtil } from './LanguageUtils'; 3 | 4 | /** 5 | * Extracted from https://github.com/i18next/i18next/blob/master/src/PluralResolver.js 6 | */ 7 | 8 | type Rule = { 9 | numbers: number[]; 10 | plurals(n: any): number; 11 | noAbs?: boolean; 12 | }; 13 | type RuleSet = Record; 14 | 15 | interface PluralResolverOptions { 16 | simplifyPluralSuffix?: boolean; 17 | prepend?: string; 18 | compatibilityJSON?: CompatibilityJSON; 19 | } 20 | 21 | const defaultOptions = { 22 | prepend: '_', 23 | simplifyPluralSuffix: true, 24 | compatibilityJSON: 'v3', 25 | } as const; 26 | 27 | // definition http://translate.sourceforge.net/wiki/l10n/pluralforms 28 | /* eslint-disable */ 29 | const sets = [ 30 | { 31 | lngs: [ 32 | 'ach', 33 | 'ak', 34 | 'am', 35 | 'arn', 36 | 'br', 37 | 'fil', 38 | 'gun', 39 | 'ln', 40 | 'mfe', 41 | 'mg', 42 | 'mi', 43 | 'oc', 44 | 'pt', 45 | 'pt-BR', 46 | 'tg', 47 | 'tl', 48 | 'ti', 49 | 'tr', 50 | 'uz', 51 | 'wa', 52 | ], 53 | nr: [1, 2], 54 | fc: 1, 55 | }, 56 | 57 | { 58 | lngs: [ 59 | 'af', 60 | 'an', 61 | 'ast', 62 | 'az', 63 | 'bg', 64 | 'bn', 65 | 'ca', 66 | 'da', 67 | 'de', 68 | 'dev', 69 | 'el', 70 | 'en', 71 | 'eo', 72 | 'es', 73 | 'et', 74 | 'eu', 75 | 'fi', 76 | 'fo', 77 | 'fur', 78 | 'fy', 79 | 'gl', 80 | 'gu', 81 | 'ha', 82 | 'hi', 83 | 'hu', 84 | 'hy', 85 | 'ia', 86 | 'it', 87 | 'kk', 88 | 'kn', 89 | 'ku', 90 | 'lb', 91 | 'mai', 92 | 'ml', 93 | 'mn', 94 | 'mr', 95 | 'nah', 96 | 'nap', 97 | 'nb', 98 | 'ne', 99 | 'nl', 100 | 'nn', 101 | 'no', 102 | 'nso', 103 | 'pa', 104 | 'pap', 105 | 'pms', 106 | 'ps', 107 | 'pt-PT', 108 | 'rm', 109 | 'sco', 110 | 'se', 111 | 'si', 112 | 'so', 113 | 'son', 114 | 'sq', 115 | 'sv', 116 | 'sw', 117 | 'ta', 118 | 'te', 119 | 'tk', 120 | 'ur', 121 | 'yo', 122 | ], 123 | nr: [1, 2], 124 | fc: 2, 125 | }, 126 | 127 | { 128 | lngs: [ 129 | 'ay', 130 | 'bo', 131 | 'cgg', 132 | 'fa', 133 | 'ht', 134 | 'id', 135 | 'ja', 136 | 'jbo', 137 | 'ka', 138 | 'km', 139 | 'ko', 140 | 'ky', 141 | 'lo', 142 | 'ms', 143 | 'sah', 144 | 'su', 145 | 'th', 146 | 'tt', 147 | 'ug', 148 | 'vi', 149 | 'wo', 150 | 'zh', 151 | ], 152 | nr: [1], 153 | fc: 3, 154 | }, 155 | 156 | { lngs: ['be', 'bs', 'cnr', 'dz', 'hr', 'ru', 'sr', 'uk'], nr: [1, 2, 5], fc: 4 }, 157 | 158 | { lngs: ['ar'], nr: [0, 1, 2, 3, 11, 100], fc: 5 }, 159 | { lngs: ['cs', 'sk'], nr: [1, 2, 5], fc: 6 }, 160 | { lngs: ['csb', 'pl'], nr: [1, 2, 5], fc: 7 }, 161 | { lngs: ['cy'], nr: [1, 2, 3, 8], fc: 8 }, 162 | { lngs: ['fr'], nr: [1, 2], fc: 9 }, 163 | { lngs: ['ga'], nr: [1, 2, 3, 7, 11], fc: 10 }, 164 | { lngs: ['gd'], nr: [1, 2, 3, 20], fc: 11 }, 165 | { lngs: ['is'], nr: [1, 2], fc: 12 }, 166 | { lngs: ['jv'], nr: [0, 1], fc: 13 }, 167 | { lngs: ['kw'], nr: [1, 2, 3, 4], fc: 14 }, 168 | { lngs: ['lt'], nr: [1, 2, 10], fc: 15 }, 169 | { lngs: ['lv'], nr: [1, 2, 0], fc: 16 }, 170 | { lngs: ['mk'], nr: [1, 2], fc: 17 }, 171 | { lngs: ['mnk'], nr: [0, 1, 2], fc: 18 }, 172 | { lngs: ['mt'], nr: [1, 2, 11, 20], fc: 19 }, 173 | { lngs: ['or'], nr: [2, 1], fc: 2 }, 174 | { lngs: ['ro'], nr: [1, 2, 20], fc: 20 }, 175 | { lngs: ['sl'], nr: [5, 1, 2, 3], fc: 21 }, 176 | { lngs: ['he', 'iw'], nr: [1, 2, 20, 21], fc: 22 }, 177 | ]; 178 | 179 | const _rulesPluralsTypes: Record number> = { 180 | 1: function(n) { 181 | return Number(n > 1); 182 | }, 183 | 2: function(n) { 184 | return Number(n != 1); 185 | }, 186 | 3: function(_n) { 187 | return 0; 188 | }, 189 | 4: function(n) { 190 | return Number( 191 | n % 10 == 1 && n % 100 != 11 192 | ? 0 193 | : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) 194 | ? 1 195 | : 2 196 | ); 197 | }, 198 | 5: function(n) { 199 | return Number( 200 | n == 0 201 | ? 0 202 | : n == 1 203 | ? 1 204 | : n == 2 205 | ? 2 206 | : n % 100 >= 3 && n % 100 <= 10 207 | ? 3 208 | : n % 100 >= 11 209 | ? 4 210 | : 5 211 | ); 212 | }, 213 | 6: function(n) { 214 | return Number(n == 1 ? 0 : n >= 2 && n <= 4 ? 1 : 2); 215 | }, 216 | 7: function(n) { 217 | return Number( 218 | n == 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2 219 | ); 220 | }, 221 | 8: function(n) { 222 | return Number(n == 1 ? 0 : n == 2 ? 1 : n != 8 && n != 11 ? 2 : 3); 223 | }, 224 | 9: function(n) { 225 | return Number(n >= 2); 226 | }, 227 | 10: function(n) { 228 | return Number(n == 1 ? 0 : n == 2 ? 1 : n < 7 ? 2 : n < 11 ? 3 : 4); 229 | }, 230 | 11: function(n) { 231 | return Number(n == 1 || n == 11 ? 0 : n == 2 || n == 12 ? 1 : n > 2 && n < 20 ? 2 : 3); 232 | }, 233 | 12: function(n) { 234 | return Number(n % 10 != 1 || n % 100 == 11); 235 | }, 236 | 13: function(n) { 237 | return Number(n !== 0); 238 | }, 239 | 14: function(n) { 240 | return Number(n == 1 ? 0 : n == 2 ? 1 : n == 3 ? 2 : 3); 241 | }, 242 | 15: function(n) { 243 | return Number( 244 | n % 10 == 1 && n % 100 != 11 ? 0 : n % 10 >= 2 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2 245 | ); 246 | }, 247 | 16: function(n) { 248 | return Number(n % 10 == 1 && n % 100 != 11 ? 0 : n !== 0 ? 1 : 2); 249 | }, 250 | 17: function(n) { 251 | return Number(n == 1 || (n % 10 == 1 && n % 100 != 11) ? 0 : 1); 252 | }, 253 | 18: function(n) { 254 | return Number(n == 0 ? 0 : n == 1 ? 1 : 2); 255 | }, 256 | 19: function(n) { 257 | return Number( 258 | n == 1 259 | ? 0 260 | : n == 0 || (n % 100 > 1 && n % 100 < 11) 261 | ? 1 262 | : n % 100 > 10 && n % 100 < 20 263 | ? 2 264 | : 3 265 | ); 266 | }, 267 | 20: function(n) { 268 | return Number(n == 1 ? 0 : n == 0 || (n % 100 > 0 && n % 100 < 20) ? 1 : 2); 269 | }, 270 | 21: function(n) { 271 | return Number(n % 100 == 1 ? 1 : n % 100 == 2 ? 2 : n % 100 == 3 || n % 100 == 4 ? 3 : 0); 272 | }, 273 | 22: function(n) { 274 | return Number(n == 1 ? 0 : n == 2 ? 1 : (n < 0 || n > 10) && n % 10 == 0 ? 2 : 3); 275 | }, 276 | }; 277 | /* eslint-enable */ 278 | 279 | const deprecatedJsonVersions = ['v1', 'v2', 'v3'] as const; 280 | const suffixesOrder = { 281 | zero: 0, 282 | one: 1, 283 | two: 2, 284 | few: 3, 285 | many: 4, 286 | other: 5, 287 | }; 288 | 289 | function createRules(): RuleSet { 290 | return sets.reduce((rules, set) => { 291 | set.lngs.forEach((l) => { 292 | rules[l] = { 293 | numbers: set.nr, 294 | plurals: _rulesPluralsTypes[set.fc], 295 | }; 296 | }); 297 | 298 | return rules; 299 | }, {} as RuleSet); 300 | } 301 | 302 | export class PluralResolver { 303 | private readonly rules: RuleSet; 304 | private options: PluralResolverOptions; 305 | private languageUtils: LanguageUtil; 306 | 307 | constructor(options: PluralResolverOptions = {}) { 308 | this.languageUtils = new LanguageUtil(); 309 | this.options = { ...defaultOptions, ...options }; 310 | 311 | if ( 312 | (!this.options.compatibilityJSON || this.options.compatibilityJSON === 'v4') && 313 | (typeof Intl === 'undefined' || !Intl.PluralRules) 314 | ) { 315 | this.options.compatibilityJSON = 'v3'; 316 | } 317 | 318 | this.rules = createRules(); 319 | } 320 | 321 | addRule(lng: string, obj: Rule) { 322 | this.rules[lng] = obj; 323 | } 324 | 325 | getRule(code: string, options: Partial<{ ordinal: true }> = {}) { 326 | if (this.shouldUseIntlApi()) { 327 | try { 328 | return new Intl.PluralRules(code, { type: options.ordinal ? 'ordinal' : 'cardinal' }); 329 | } catch { 330 | return; 331 | } 332 | } 333 | 334 | return this.rules[code] || this.rules[this.languageUtils.getLanguagePartFromCode(code)]; 335 | } 336 | 337 | needsPlural(code: string, options = {}) { 338 | const rule = this.getRule(code, options); 339 | 340 | if (this.shouldUseIntlApi(rule)) { 341 | return rule && rule.resolvedOptions().pluralCategories.length > 1; 342 | } 343 | 344 | return rule && rule.numbers.length > 1; 345 | } 346 | 347 | // Added method 348 | getSingularFormOfKey(code: string, key: string): string { 349 | const suffixes = this.getSuffixes(code).filter(Boolean); 350 | 351 | const suffix = suffixes.find((suffix) => key.endsWith(suffix)); 352 | if (!suffix) { 353 | return key; 354 | } 355 | 356 | return key.substring(0, key.length - suffix.length); 357 | } 358 | 359 | getPluralFormsOfKey(code: string, key: string, options = {}) { 360 | return this.getSuffixes(code, options).map((suffix: string) => `${key}${suffix}`); 361 | } 362 | 363 | getSuffixes(code: string, options = {}) { 364 | const rule = this.getRule(code, options); 365 | 366 | if (!rule) { 367 | return []; 368 | } 369 | 370 | if (this.shouldUseIntlApi(rule)) { 371 | return rule 372 | .resolvedOptions() 373 | .pluralCategories.sort( 374 | (pluralCategory1, pluralCategory2) => 375 | suffixesOrder[pluralCategory1] - suffixesOrder[pluralCategory2] 376 | ) 377 | .map((pluralCategory) => `${this.options.prepend}${pluralCategory}`); 378 | } 379 | 380 | return rule.numbers.map((number) => this.getSuffix(code, number, options)); 381 | } 382 | 383 | getSuffix(code: string, count: number, options = {}): string { 384 | const rule = this.getRule(code, options); 385 | 386 | if (rule) { 387 | if (this.shouldUseIntlApi(rule)) { 388 | return `${this.options.prepend}${rule.select(count)}`; 389 | } 390 | 391 | return this.getSuffixRetroCompatible(rule, count); 392 | } 393 | 394 | return ''; 395 | } 396 | 397 | getSuffixRetroCompatible(rule: Rule, count: number) { 398 | const idx = rule.noAbs ? rule.plurals(count) : rule.plurals(Math.abs(count)); 399 | let suffix: string | number = rule.numbers[idx]; 400 | 401 | // special treatment for lngs only having singular and plural 402 | if (this.options.simplifyPluralSuffix && rule.numbers.length === 2 && rule.numbers[0] === 1) { 403 | if (suffix === 2) { 404 | suffix = 'plural'; 405 | } else if (suffix === 1) { 406 | suffix = ''; 407 | } 408 | } 409 | 410 | const returnSuffix = () => 411 | this.options.prepend && suffix.toString() 412 | ? this.options.prepend + suffix.toString() 413 | : suffix.toString(); 414 | 415 | // COMPATIBILITY JSON 416 | // v1 417 | if (this.options.compatibilityJSON === 'v1') { 418 | if (suffix === 1) return ''; 419 | if (typeof suffix === 'number') return `_plural_${suffix.toString()}`; 420 | return returnSuffix(); 421 | } else if (/* v2 */ this.options.compatibilityJSON === 'v2') { 422 | return returnSuffix(); 423 | } else if ( 424 | /* v3 - gettext index */ this.options.simplifyPluralSuffix && 425 | rule.numbers.length === 2 && 426 | rule.numbers[0] === 1 427 | ) { 428 | return returnSuffix(); 429 | } 430 | return this.options.prepend && idx.toString() 431 | ? this.options.prepend + idx.toString() 432 | : idx.toString(); 433 | } 434 | 435 | shouldUseIntlApi(_rule?: Rule | Intl.PluralRules): _rule is Intl.PluralRules { 436 | return !deprecatedJsonVersions.includes(this.options.compatibilityJSON as any); 437 | } 438 | } 439 | --------------------------------------------------------------------------------