├── .eslintignore ├── .gitignore ├── .npmignore ├── index.ts ├── bamboo-specs ├── bamboo.yaml ├── permissions.yaml ├── deploy.yaml ├── increment.yaml ├── test.yaml └── build.yaml ├── babel.config.js ├── .eslintrc.js ├── jest.config.ts ├── tools └── build-txt.ts ├── rollup.config.js ├── tsconfig.json ├── CHANGELOG.md ├── src ├── translate.ts ├── nodes.ts ├── Translator.ts ├── validator.ts ├── plugins │ ├── react.ts │ └── preact.ts ├── formatter.ts ├── parser.ts └── plural.ts ├── LICENSE ├── package.json ├── tests ├── formatter.test.ts ├── translate.test.ts ├── validator.test.ts └── parser.test.ts └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | yarn-error.log 2 | coverage/ 3 | node_modules/ 4 | dist/ 5 | docs/ 6 | .vscode 7 | .DS_Store 8 | 9 | .npmrc 10 | *.tgz 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | src 3 | docs 4 | tests 5 | jest.config.ts 6 | tsconfig.json 7 | rollup.config.js 8 | babel.config.js 9 | .eslintignore 10 | .idea 11 | .eslintrc.js 12 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { I18nInterface, Translator } from './src/Translator'; 2 | export { translate } from './src/translate'; 3 | export { validator } from './src/validator'; 4 | export { Locale } from './src/plural'; 5 | -------------------------------------------------------------------------------- /bamboo-specs/bamboo.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | !include 'build.yaml' 3 | 4 | --- 5 | !include 'deploy.yaml' 6 | 7 | --- 8 | !include 'increment.yaml' 9 | 10 | --- 11 | !include 'permissions.yaml' 12 | 13 | --- 14 | !include 'test.yaml' 15 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { browsers: 'defaults', node: 10 } }], 4 | '@babel/preset-typescript' 5 | ], 6 | "plugins": ["@babel/plugin-proposal-class-properties"] 7 | }; 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | ], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/en/configuration.html 4 | */ 5 | export default { 6 | testEnvironment: "node", 7 | collectCoverage: true, 8 | coverageDirectory: "coverage", 9 | }; 10 | -------------------------------------------------------------------------------- /bamboo-specs/permissions.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | deployment: 4 | name: translate - deploy 5 | deployment-permissions: 6 | - groups: 7 | - extensions-developers 8 | permissions: 9 | - view 10 | environment-permissions: 11 | - npmjs: 12 | - groups: 13 | - extensions-developers 14 | permissions: 15 | - view 16 | - deploy 17 | -------------------------------------------------------------------------------- /tools/build-txt.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs-extra'; 3 | 4 | import { version } from '../package.json'; 5 | 6 | const DIST_DIR = '../dist'; 7 | const BUILD_TXT_FILENAME = 'build.txt'; 8 | 9 | const buildTxt = async () => { 10 | const content = `version=${version}`; 11 | await fs.ensureDir(DIST_DIR); 12 | await fs.writeFile(path.resolve(__dirname, DIST_DIR, BUILD_TXT_FILENAME), content); 13 | }; 14 | 15 | buildTxt(); 16 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@wessberg/rollup-plugin-ts'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | 5 | import pkg from './package.json'; 6 | 7 | const config = { 8 | input: 'index.ts', 9 | output: [ 10 | { 11 | file: pkg.main, 12 | format: 'cjs', 13 | }, 14 | { 15 | file: pkg.module, 16 | format: 'esm', 17 | } 18 | ], 19 | plugins: [ 20 | typescript({ 21 | transpiler: "babel" 22 | }), 23 | commonjs(), 24 | resolve(), 25 | ] 26 | } 27 | 28 | export default [config]; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["esnext"], 4 | "target": "es5", 5 | "module": "es2015", 6 | "allowJs": true, 7 | "strict": true, 8 | "outDir": "dist", 9 | "emitDeclarationOnly": true, 10 | "declaration": true, 11 | "declarationDir": "dist/types", 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "rootDir": "", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "noImplicitAny": true, 18 | }, 19 | "ts-node": { 20 | "compilerOptions": { 21 | "module": "commonjs" 22 | } 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Translate Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 9 | ## 2.0.1 - 2025-12-12 10 | 11 | ### Changed 12 | 13 | - [BREAKING CHANGE] Single '%' signs are not allowed anymore and should be escaped by extra percent sign '%%'. 14 | 15 | ### Added 16 | 17 | - Error throwing on unsupported locale passed to `isTranslationValid()` and `isPluralFormValid()`. 18 | 19 | ### Fixed 20 | 21 | - More specific error logging of `getMessage()` and `getPluralForm()`. 22 | 23 | 24 | ## 1.0.2 - 2023-09-26 25 | 26 | ### Changed 27 | 28 | - `isTranslationValid()` and `isPluralFormValid()` methods. 29 | -------------------------------------------------------------------------------- /src/translate.ts: -------------------------------------------------------------------------------- 1 | import { I18nInterface, MessageConstructorInterface, Translator } from './Translator'; 2 | import { ValuesAny } from './formatter'; 3 | import { createReactTranslator } from './plugins/react'; 4 | import { createPreactTranslator } from './plugins/preact'; 5 | 6 | /** 7 | * Creates translator instance strings, by default for simple strings 8 | * @param i18n - function which returns translated message by key 9 | * @param messageConstructor - function that will collect messages 10 | * @param values - map with default values for tag converters 11 | */ 12 | const createTranslator = ( 13 | i18n: I18nInterface, 14 | messageConstructor?: MessageConstructorInterface, 15 | values?: ValuesAny, 16 | ): Translator => { 17 | return new Translator(i18n, messageConstructor, values); 18 | }; 19 | 20 | const translate = { 21 | createTranslator, 22 | createReactTranslator, 23 | createPreactTranslator, 24 | }; 25 | 26 | export { translate }; 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Adguard Software Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bamboo-specs/deploy.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | deployment: 4 | name: translate - deploy 5 | source-plan: AJL-TRNSLTBUILD 6 | release-naming: ${bamboo.inject.version} 7 | environments: 8 | - npmjs 9 | 10 | npmjs: 11 | docker: 12 | image: adguard/node-ssh:18.13--0 13 | volumes: 14 | ${system.YARN_DIR}: "${bamboo.cacheYarn}" 15 | triggers: [] 16 | tasks: 17 | - checkout: 18 | force-clean-build: true 19 | - artifact-download: 20 | artifacts: 21 | - name: translate.tgz 22 | - script: 23 | interpreter: SHELL 24 | scripts: 25 | - |- 26 | set -e 27 | set -x 28 | 29 | # Fix mixed logs 30 | exec 2>&1 31 | 32 | ls -alt 33 | 34 | export NPM_TOKEN=${bamboo.npmSecretToken} 35 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc 36 | npm publish translate.tgz --access public 37 | requirements: 38 | - adg-docker: 'true' 39 | - extension: 'true' 40 | notifications: 41 | - events: 42 | - deployment-started-and-finished 43 | recipients: 44 | - webhook: 45 | name: Deploy webhook 46 | url: http://prod.jirahub.service.eu.consul/v1/webhook/bamboo 47 | -------------------------------------------------------------------------------- /bamboo-specs/increment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | plan: 4 | project-key: AJL 5 | key: TRNSLTINCR 6 | name: translate - increment 7 | variables: 8 | dockerContainer: adguard/node-ssh:18.13--0 9 | 10 | stages: 11 | - Increment: 12 | manual: false 13 | final: false 14 | jobs: 15 | - Increment 16 | 17 | Increment: 18 | key: INCR 19 | docker: 20 | image: "${bamboo.dockerContainer}" 21 | volumes: 22 | ${system.YARN_DIR}: "${bamboo.cacheYarn}" 23 | other: 24 | clean-working-dir: true 25 | tasks: 26 | - checkout: 27 | force-clean-build: true 28 | - script: 29 | interpreter: SHELL 30 | scripts: 31 | - |- 32 | set -e 33 | set -x 34 | 35 | # Fix mixed logs 36 | exec 2>&1 37 | 38 | yarn increment 39 | - any-task: 40 | plugin-key: com.atlassian.bamboo.plugins.vcs:task.vcs.commit 41 | configuration: 42 | commitMessage: 'skipci: Automatic increment build number' 43 | selectedRepository: defaultRepository 44 | requirements: 45 | - adg-docker: 'true' 46 | - extension: 'true' 47 | 48 | branches: 49 | create: manually 50 | delete: never 51 | link-to-jira: true 52 | 53 | labels: [] 54 | other: 55 | concurrent-build-plugin: system-default 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adguard/translate", 3 | "version": "2.0.3", 4 | "main": "dist/index.js", 5 | "module": "dist/index.esm.js", 6 | "types": "dist/types/index.d.ts", 7 | "author": "Adguard Software Ltd", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/AdguardTeam/translate.git" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "rollup -c", 18 | "prepublishOnly": "npm run build", 19 | "test": "jest", 20 | "lint": "eslint src tests", 21 | "increment": "yarn version --patch --no-git-tag-version", 22 | "build-txt": "ts-node tools/build-txt", 23 | "docs": "typedoc --out docs src" 24 | }, 25 | "devDependencies": { 26 | "@babel/core": "^7.12.10", 27 | "@babel/plugin-proposal-class-properties": "^7.12.1", 28 | "@babel/preset-env": "^7.12.10", 29 | "@babel/preset-typescript": "^7.12.7", 30 | "@rollup/plugin-commonjs": "^17.0.0", 31 | "@rollup/plugin-node-resolve": "^11.0.1", 32 | "@types/fs-extra": "^11.0.2", 33 | "@types/jest": "^26.0.19", 34 | "@types/react": "^17.0.38", 35 | "@typescript-eslint/eslint-plugin": "^4.10.0", 36 | "@typescript-eslint/parser": "^4.10.0", 37 | "@wessberg/rollup-plugin-ts": "^1.3.8", 38 | "babel-jest": "^26.6.3", 39 | "eslint": "^7.15.0", 40 | "jest": "^29.7.0", 41 | "preact": "^10.6.4", 42 | "react": "^17.0.1", 43 | "rollup": "^2.35.1", 44 | "ts-node": "^9.1.1", 45 | "typedoc": "^0.19.2", 46 | "typescript": "^4.1.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/nodes.ts: -------------------------------------------------------------------------------- 1 | enum NODE_TYPES { 2 | PLACEHOLDER = 'placeholder', 3 | TEXT = 'text', 4 | TAG = 'tag', 5 | VOID_TAG = 'void_tag', 6 | } 7 | 8 | export interface Node { 9 | type: NODE_TYPES, 10 | value: string, 11 | children?: Node[], 12 | } 13 | 14 | export const isTextNode = (node: Node): boolean => { 15 | return node.type === NODE_TYPES.TEXT; 16 | }; 17 | 18 | export const isTagNode = (node: Node): boolean => { 19 | return node.type === NODE_TYPES.TAG; 20 | }; 21 | 22 | export const isPlaceholderNode = (node: Node): boolean => { 23 | return node.type === NODE_TYPES.PLACEHOLDER; 24 | }; 25 | 26 | export const isVoidTagNode = (node: Node): boolean => { 27 | return node.type === NODE_TYPES.VOID_TAG; 28 | }; 29 | 30 | export const placeholderNode = (value: string): Node => { 31 | return { type: NODE_TYPES.PLACEHOLDER, value }; 32 | }; 33 | 34 | export const textNode = (str: string): Node => { 35 | return { type: NODE_TYPES.TEXT, value: str }; 36 | }; 37 | 38 | export const tagNode = (tagName: string, children: Node[]): Node => { 39 | const value = tagName.trim(); 40 | return { type: NODE_TYPES.TAG, value, children }; 41 | }; 42 | 43 | export const voidTagNode = (tagName: string): Node => { 44 | const value = tagName.trim(); 45 | return { type: NODE_TYPES.VOID_TAG, value }; 46 | }; 47 | 48 | /** 49 | * Checks if target is node 50 | * @param target 51 | */ 52 | export const isNode = (target: Node | string): boolean => { 53 | if (typeof target === 'string') { 54 | return false; 55 | } 56 | return !!target.type; 57 | }; 58 | -------------------------------------------------------------------------------- /bamboo-specs/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | plan: 4 | project-key: AJL 5 | key: TRNSLTTEST 6 | name: translate - test 7 | variables: 8 | dockerContainer: adguard/node-ssh:18.13--0 9 | 10 | stages: 11 | - Build: 12 | manual: false 13 | final: false 14 | jobs: 15 | - Build 16 | 17 | Build: 18 | key: BUILD 19 | docker: 20 | image: "${bamboo.dockerContainer}" 21 | volumes: 22 | ${system.YARN_DIR}: "${bamboo.cacheYarn}" 23 | tasks: 24 | - checkout: 25 | force-clean-build: true 26 | - script: 27 | interpreter: SHELL 28 | scripts: 29 | - |- 30 | set -e 31 | set -x 32 | 33 | # Fix mixed logs 34 | exec 2>&1 35 | 36 | yarn install ${bamboo.varsYarn} 37 | 38 | yarn lint 39 | yarn test 40 | 41 | yarn build 42 | final-tasks: 43 | - script: 44 | interpreter: SHELL 45 | scripts: 46 | - |- 47 | set -x 48 | set -e 49 | 50 | # Fix mixed logs 51 | exec 2>&1 52 | 53 | ls -la 54 | 55 | echo "Size before cleanup:" && du -h | tail -n 1 56 | rm -rf node_modules 57 | echo "Size after cleanup:" && du -h | tail -n 1 58 | requirements: 59 | - adg-docker: 'true' 60 | - extension: 'true' 61 | 62 | branches: 63 | create: for-pull-request 64 | delete: 65 | after-deleted-days: '1' 66 | after-inactive-days: '5' 67 | link-to-jira: true 68 | 69 | notifications: 70 | - events: 71 | - plan-status-changed 72 | recipients: 73 | - webhook: 74 | name: Build webhook 75 | url: http://prod.jirahub.service.eu.consul/v1/webhook/bamboo 76 | 77 | labels: [] 78 | other: 79 | concurrent-build-plugin: system-default 80 | -------------------------------------------------------------------------------- /bamboo-specs/build.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | plan: 4 | project-key: AJL 5 | key: TRNSLTBUILD 6 | name: translate - build 7 | variables: 8 | dockerContainer: adguard/node-ssh:18.13--0 9 | 10 | stages: 11 | - Build: 12 | manual: false 13 | final: false 14 | jobs: 15 | - Build 16 | 17 | Build: 18 | key: BUILD 19 | other: 20 | clean-working-dir: true 21 | docker: 22 | image: "${bamboo.dockerContainer}" 23 | volumes: 24 | ${system.YARN_DIR}: "${bamboo.cacheYarn}" 25 | tasks: 26 | - checkout: 27 | force-clean-build: true 28 | - script: 29 | interpreter: SHELL 30 | scripts: 31 | - |- 32 | set -e 33 | set -x 34 | 35 | # Fix mixed logs 36 | exec 2>&1 37 | 38 | ls -alt 39 | 40 | yarn install ${bamboo.varsYarn} 41 | yarn build 42 | yarn build-txt 43 | 44 | yarn pack --filename translate.tgz 45 | 46 | ls -la 47 | - inject-variables: 48 | file: dist/build.txt 49 | scope: RESULT 50 | namespace: inject 51 | - any-task: 52 | plugin-key: com.atlassian.bamboo.plugins.vcs:task.vcs.tagging 53 | configuration: 54 | selectedRepository: defaultRepository 55 | tagName: v${bamboo.inject.version} 56 | final-tasks: 57 | - script: 58 | interpreter: SHELL 59 | scripts: 60 | - |- 61 | set -x 62 | set -e 63 | 64 | # Fix mixed logs 65 | exec 2>&1 66 | 67 | ls -la 68 | 69 | echo "Size before cleanup:" && du -h | tail -n 1 70 | rm -rf node_modules 71 | echo "Size after cleanup:" && du -h | tail -n 1 72 | artifacts: 73 | - name: translate.tgz 74 | location: ./ 75 | pattern: translate.tgz 76 | shared: true 77 | required: true 78 | requirements: 79 | - adg-docker: 'true' 80 | - extension: 'true' 81 | 82 | triggers: [] 83 | 84 | branches: 85 | create: manually 86 | delete: never 87 | link-to-jira: true 88 | 89 | notifications: 90 | - events: 91 | - plan-status-changed 92 | recipients: 93 | - webhook: 94 | name: Build webhook 95 | url: http://prod.jirahub.service.eu.consul/v1/webhook/bamboo 96 | 97 | labels: [] 98 | 99 | other: 100 | concurrent-build-plugin: system-default 101 | -------------------------------------------------------------------------------- /tests/formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { formatter } from '../src/formatter'; 2 | 3 | describe('formatter', () => { 4 | it('formats', () => { 5 | const message = formatter('test1', 'some text', { 6 | a: (chunks: string) => `${chunks}`, 7 | }); 8 | 9 | expect(message) 10 | .toEqual(['some text']); 11 | }); 12 | 13 | it('formats nested messages', () => { 14 | const message = formatter('test2', 'before tag text some text inside span tag', { 15 | a: (chunks: string) => `${chunks}`, 16 | b: (chunks: string) => `${chunks}`, 17 | }); 18 | 19 | expect(message) 20 | .toEqual(['before tag text ', 'some text inside span tag']); 21 | }); 22 | 23 | it('formats placeholders', () => { 24 | const rawStr = 'Ping %pingValue% ms'; 25 | const formatted = formatter('test3', rawStr, { 26 | pingValue: 100, 27 | }); 28 | 29 | expect(formatted).toEqual(['Ping ', '100', ' ms']); 30 | }); 31 | 32 | it('formats nested placeholders', () => { 33 | const rawStr = '%value% %unit% remaining this month'; 34 | 35 | const formatted = formatter('test4', rawStr, { 36 | value: 10, 37 | unit: 'kb', 38 | span: (chunks: string) => (`${chunks}`), 39 | }); 40 | 41 | expect(formatted).toEqual(["10 kb", ' remaining this month']); 42 | }); 43 | 44 | it('formats placeholder nested in tag', () => { 45 | const rawStr = 'You are signing in as
%username%
'; 46 | const formatted = formatter('test5', rawStr, { 47 | username: 'maximtop@gmail.com', 48 | div: (chunks: string) => (`
${chunks}
`), 49 | }); 50 | expect(formatted).toEqual(['You are signing in as ', "
maximtop@gmail.com
"]); 51 | }); 52 | 53 | it('handles empty input without errors', () => { 54 | const formatted = formatter(); 55 | expect(formatted).toEqual([]); 56 | }); 57 | 58 | describe('void tags', () => { 59 | it('formats void tags', () => { 60 | const rawStr = 'cat float'; 61 | const formatted = formatter('test6', rawStr, { 62 | img: '', 63 | }); 64 | 65 | expect(formatted).toEqual(['cat ', '', ' float']); 66 | }); 67 | }); 68 | 69 | it('handles tags without replacement', () => { 70 | const rawStr = '

Text inside tag

'; 71 | const formatted = formatter('test7', rawStr); 72 | expect(formatted).toEqual(['

Text inside tag

']); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/Translator.ts: -------------------------------------------------------------------------------- 1 | import { formatter, ValuesAny } from './formatter'; 2 | import { Locale, getForm } from './plural'; 3 | 4 | interface TranslatorInterface { 5 | getMessage(key: string, params: ValuesAny): T; 6 | 7 | getPlural(key: string, number: number, params: ValuesAny): T; 8 | } 9 | 10 | export interface I18nInterface { 11 | /** 12 | * Returns message by key for current locale 13 | * @param key 14 | */ 15 | getMessage(key: string): string; 16 | 17 | /** 18 | * Returns current locale code 19 | * Locale codes should be in the list of Locale 20 | */ 21 | getUILanguage(): Locale; 22 | 23 | /** 24 | * Returns base locale message 25 | * @param key 26 | */ 27 | getBaseMessage(key: string): string; 28 | 29 | /** 30 | * Returns base locale code 31 | */ 32 | getBaseUILanguage(): Locale; 33 | } 34 | 35 | export type MessageConstructorInterface = (formatted: string[]) => T; 36 | 37 | const defaultMessageConstructor: MessageConstructorInterface = (formatted: string[]) => { 38 | return formatted.join(''); 39 | }; 40 | 41 | export class Translator implements TranslatorInterface { 42 | private i18n: I18nInterface; 43 | 44 | private readonly messageConstructor: MessageConstructorInterface; 45 | 46 | private values: ValuesAny; 47 | 48 | constructor( 49 | i18n: I18nInterface, 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | messageConstructor?: MessageConstructorInterface, 52 | values?: ValuesAny, 53 | ) { 54 | this.i18n = i18n; 55 | this.messageConstructor = messageConstructor || defaultMessageConstructor; 56 | this.values = values || {}; 57 | } 58 | 59 | /** 60 | * Retrieves message and translates it, substituting parameters where necessary 61 | * @param key - translation message key 62 | * @param params - values used to substitute placeholders and tags 63 | */ 64 | public getMessage(key: string, params: ValuesAny = {}): T { 65 | let message = this.i18n.getMessage(key); 66 | if (!message) { 67 | message = this.i18n.getBaseMessage(key); 68 | if (!message) { 69 | throw new Error(`Was unable to find message for key: "${key}"`); 70 | } 71 | } 72 | const formatted = formatter(key, message, { ...this.values, ...params }); 73 | return this.messageConstructor(formatted); 74 | } 75 | 76 | /** 77 | * Retrieves correct plural form and translates it 78 | * @param key - translation message key 79 | * @param number - plural form number 80 | * @param params - values used to substitute placeholders or tags if necessary, 81 | * if params has "count" property it will be overridden by number (plural form number) 82 | */ 83 | public getPlural(key: string, number: number, params: ValuesAny = {}): T { 84 | let message = this.i18n.getMessage(key); 85 | let language = this.i18n.getUILanguage(); 86 | if (!message) { 87 | message = this.i18n.getBaseMessage(key); 88 | if (!message) { 89 | throw new Error(`Was unable to find message for key: "${key}"`); 90 | } 91 | language = this.i18n.getBaseUILanguage(); 92 | } 93 | const form = getForm(message, number, language, key); 94 | const formatted = formatter(key, form, { ...this.values, ...params, count: number }); 95 | return this.messageConstructor(formatted); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/validator.ts: -------------------------------------------------------------------------------- 1 | import { parser } from './parser'; 2 | import { isTextNode, Node } from './nodes'; 3 | import { 4 | getForms, 5 | hasPluralForm, 6 | isPluralFormValid, 7 | Locale, 8 | } from './plural'; 9 | 10 | /** 11 | * Compares two AST (abstract syntax tree) structures, 12 | * view tests for examples 13 | * @param baseAst 14 | * @param targetAst 15 | */ 16 | const areAstStructuresSame = (baseAst: Node[], targetAst: Node[]): boolean => { 17 | const textNodeFilter = (node: Node) => { 18 | return !isTextNode(node); 19 | }; 20 | 21 | const filteredBaseAst = baseAst.filter(textNodeFilter); 22 | 23 | const filteredTargetAst = targetAst.filter(textNodeFilter); 24 | 25 | // if AST structures have different lengths, they are not equal 26 | if (filteredBaseAst.length !== filteredTargetAst.length) { 27 | return false; 28 | } 29 | 30 | for (let i = 0; i < filteredBaseAst.length; i += 1) { 31 | const baseNode = filteredBaseAst[i]; 32 | 33 | const targetNode = filteredTargetAst.find((node) => { 34 | return node.type === baseNode.type && node.value === baseNode.value; 35 | }); 36 | 37 | if (!targetNode) { 38 | return false; 39 | } 40 | 41 | if (targetNode.children && baseNode.children) { 42 | const areChildrenSame = areAstStructuresSame(baseNode.children, targetNode.children); 43 | if (!areChildrenSame) { 44 | return false; 45 | } 46 | } 47 | } 48 | 49 | return true; 50 | }; 51 | 52 | /** 53 | * Validates translation against base string by AST (abstract syntax tree) structure. 54 | * 55 | * @param baseMessage Base message. 56 | * @param translatedMessage Translated message. 57 | * @param locale Locale of `translatedMessage`. 58 | * 59 | * @returns True if translated message is valid, false otherwise: 60 | * - if base message has no plural forms, it will return true if AST structures are same; 61 | * - if base message has plural forms, first of all 62 | * the function checks if the number of plural forms is correct for the `locale`, 63 | * and then it validates AST plural forms structures for base and translated messages. 64 | * 65 | * @throws Error for invalid tags in base or translated messages, 66 | * if translated message has invalid plural forms, 67 | * or if base or translated message has unclosed placeholder markers. 68 | */ 69 | export const isTranslationValid = ( 70 | baseMessage: string, 71 | translatedMessage: string, 72 | locale: Locale, 73 | ): boolean => { 74 | if (hasPluralForm(baseMessage)) { 75 | const isPluralFormsValid = isPluralFormValid(translatedMessage, locale); 76 | if (!isPluralFormsValid) { 77 | throw new Error('Invalid plural forms'); 78 | } 79 | 80 | const baseForms = getForms(baseMessage); 81 | const translatedForms = getForms(translatedMessage); 82 | 83 | // check a zero form structures of base and translated messages 84 | if (!isTranslationValid(baseForms[0], translatedForms[0], locale)) { 85 | return false; 86 | } 87 | // and check other forms structures of translated messages against the first form of base message 88 | for (let i = 1; i < translatedForms.length; i += 1) { 89 | if (!isTranslationValid(baseForms[1], translatedForms[i], locale)) { 90 | return false; 91 | } 92 | } 93 | // if no errors, return true after all checks 94 | return true; 95 | } 96 | 97 | const baseMessageAst = parser(baseMessage); 98 | const translatedMessageAst = parser(translatedMessage); 99 | 100 | return areAstStructuresSame(baseMessageAst, translatedMessageAst); 101 | }; 102 | 103 | export const validator = { 104 | isTranslationValid, 105 | isPluralFormValid, 106 | }; 107 | -------------------------------------------------------------------------------- /src/plugins/react.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import { I18nInterface, Translator } from '../Translator'; 3 | 4 | export interface ReactCustom { 5 | createElement: typeof React.createElement 6 | Children: React.ReactChildren 7 | } 8 | 9 | /** 10 | * Creates translation function for strings used in the React components 11 | * We do not import React directly, because translator module can be used 12 | * in the modules without React too 13 | * 14 | * e.g. 15 | * const translateReact = createReactTranslator(getMessage, React); 16 | * in locales folder you should have messages.json file 17 | * ``` 18 | * message: 19 | * "popup_auth_agreement_consent": { 20 | * "message": "You agree to our EULA", 21 | * }, 22 | * ``` 23 | * 24 | * this message can be retrieved and translated into react components next way: 25 | * 26 | * const component = translateReact('popup_auth_agreement_consent', { 27 | * eula: (chunks) => ( 28 | * 34 | * ), 35 | * }); 36 | * 37 | * Note how functions in the values argument can be used with handlers 38 | * 39 | * @param i18n - object with methods which get translated message by key and return current locale 40 | * @param React - instance of react library 41 | */ 42 | export const createReactTranslator = ( 43 | i18n: I18nInterface, react: ReactCustom, defaults?: { 44 | override?: boolean, 45 | tags: { 46 | key: string, 47 | createdTag: string, 48 | }[] 49 | } 50 | ): Translator => { 51 | /** 52 | * Helps to build nodes without values 53 | * 54 | * @param tagName 55 | * @param children 56 | */ 57 | const createReactElement = (tagName: string, children: React.ReactChildren) => { 58 | if (children) { 59 | return react.createElement(tagName, null, react.Children.toArray(children)); 60 | } 61 | return react.createElement(tagName, null); 62 | }; 63 | 64 | /** 65 | * Function creates default values to be used if user didn't provide function values for tags 66 | */ 67 | const createDefaultValues = () => { 68 | // eslint-disable-next-line @typescript-eslint/ban-types 69 | const externalDefaults: Record = {}; 70 | if (defaults) { 71 | defaults.tags.forEach((t) => { 72 | externalDefaults[t.key] = (children: React.ReactChildren) => createReactElement(t.createdTag, children); 73 | }); 74 | } 75 | if (defaults?.override) { 76 | return externalDefaults; 77 | } 78 | return ({ 79 | p: (children: React.ReactChildren) => createReactElement('p', children), 80 | b: (children: React.ReactChildren) => createReactElement('b', children), 81 | strong: (children: React.ReactChildren) => createReactElement('strong', children), 82 | tt: (children: React.ReactChildren) => createReactElement('tt', children), 83 | s: (children: React.ReactChildren) => createReactElement('s', children), 84 | i: (children: React.ReactChildren) => createReactElement('i', children), 85 | ...externalDefaults 86 | }); 87 | }; 88 | 89 | const reactMessageConstructor = (formatted: string[]): React.ReactNode => { 90 | const reactChildren = react.Children.toArray(formatted); 91 | 92 | // if there is only strings in the array we join them 93 | if (reactChildren.every((child: React.ReactNode | string) => typeof child === 'string')) { 94 | return reactChildren.join(''); 95 | } 96 | 97 | return reactChildren; 98 | }; 99 | 100 | const defaultValues = createDefaultValues(); 101 | 102 | return new Translator(i18n, reactMessageConstructor, defaultValues); 103 | }; 104 | -------------------------------------------------------------------------------- /src/plugins/preact.ts: -------------------------------------------------------------------------------- 1 | import type Preact from 'preact'; 2 | import { toChildArray } from 'preact'; 3 | import { I18nInterface, Translator } from '../Translator'; 4 | 5 | export interface PreactCustom { 6 | createElement: typeof Preact.createElement 7 | } 8 | 9 | /** 10 | * Creates translation function for strings used in the Preact components 11 | * We do not import Preact directly, because translator module can be used 12 | * in the modules without Preact too 13 | * 14 | * e.g. 15 | * const translatePreact = createPreactTranslator(getMessage, Preact); 16 | * in locales folder you should have messages.json file 17 | * ``` 18 | * message: 19 | * "popup_auth_agreement_consent": { 20 | * "message": "You agree to our EULA", 21 | * }, 22 | * ``` 23 | * 24 | * this message can be retrieved and translated into preact components next way: 25 | * 26 | * const component = translatePreact('popup_auth_agreement_consent', { 27 | * eula: (chunks) => ( 28 | * 34 | * ), 35 | * }); 36 | * 37 | * Note how functions in the values argument can be used with handlers 38 | * 39 | * @param i18n - object with methods which get translated message by key and return current locale 40 | * @param Preact - instance of preact library 41 | */ 42 | export const createPreactTranslator = ( 43 | i18n: I18nInterface, preact: PreactCustom, defaults?: { 44 | override?: boolean, 45 | tags: { 46 | key: string, 47 | createdTag: string, 48 | }[] 49 | } 50 | ): Translator => { 51 | /** 52 | * Helps to build nodes without values 53 | * 54 | * @param tagName 55 | * @param children 56 | */ 57 | const createPreactElement = (tagName: string, children: Preact.ComponentChildren) => { 58 | if (children) { 59 | return preact.createElement(tagName, null, toChildArray(children)); 60 | } 61 | return preact.createElement(tagName, null); 62 | }; 63 | 64 | /** 65 | * Function creates default values to be used if user didn't provide function values for tags 66 | */ 67 | const createDefaultValues = () => { 68 | // eslint-disable-next-line @typescript-eslint/ban-types 69 | const externalDefaults: Record = {}; 70 | if (defaults) { 71 | defaults.tags.forEach((t) => { 72 | externalDefaults[t.key] = (children: Preact.ComponentChildren) => createPreactElement(t.createdTag, children); 73 | }); 74 | } 75 | if (defaults?.override) { 76 | return externalDefaults; 77 | } 78 | return ({ 79 | p: (children: Preact.ComponentChildren) => createPreactElement('p', children), 80 | b: (children: Preact.ComponentChildren) => createPreactElement('b', children), 81 | strong: (children: Preact.ComponentChildren) => createPreactElement('strong', children), 82 | tt: (children: Preact.ComponentChildren) => createPreactElement('tt', children), 83 | s: (children: Preact.ComponentChildren) => createPreactElement('s', children), 84 | i: (children: Preact.ComponentChildren) => createPreactElement('i', children), 85 | ...externalDefaults 86 | }); 87 | }; 88 | 89 | const preactMessageConstructor = (formatted: string[]): Preact.ComponentChildren => { 90 | const preactChildren = toChildArray(formatted); 91 | 92 | // if there is only strings in the array we join them 93 | if (preactChildren.every((child: Preact.ComponentChildren | string) => typeof child === 'string')) { 94 | return preactChildren.join(''); 95 | } 96 | 97 | return preactChildren; 98 | }; 99 | 100 | const defaultValues = createDefaultValues(); 101 | 102 | return new Translator(i18n, preactMessageConstructor, defaultValues); 103 | }; 104 | -------------------------------------------------------------------------------- /tests/translate.test.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { translate } from '../src/translate'; 4 | import { Locale } from "../src/plural"; 5 | 6 | interface MessagesInterface { 7 | [key: string]: string; 8 | } 9 | 10 | describe('translate', () => { 11 | describe('createTranslator', () => { 12 | const i18n = (() => { 13 | const messages: MessagesInterface = { 14 | simple: 'bold in the text', 15 | plural: '| %count% hour | %count% hours' 16 | }; 17 | 18 | return { 19 | getMessage(key: string): string { 20 | return messages[key]; 21 | }, 22 | 23 | getUILanguage(): Locale { 24 | return 'en'; 25 | }, 26 | 27 | getBaseMessage(key: string): string { 28 | return messages[key]; 29 | }, 30 | 31 | getBaseUILanguage(): Locale { 32 | return 'en'; 33 | } 34 | } 35 | })(); 36 | 37 | const translator = translate.createTranslator(i18n); 38 | 39 | it('translates singular strings', () => { 40 | const message = translator.getMessage('simple'); 41 | expect(message).toBe('bold in the text'); 42 | }); 43 | 44 | it('translates plural strings', () => { 45 | let message = translator.getPlural('plural', 1, { count: 1 }); 46 | expect(message).toBe('1 hour'); 47 | 48 | message = translator.getPlural('plural', 2, { count: 2 }); 49 | expect(message).toBe('2 hours'); 50 | }); 51 | }); 52 | 53 | describe('createReactTranslator', () => { 54 | const i18n = (() => { 55 | const messages: MessagesInterface = { 56 | simple: 'bold in the text', 57 | str_with_percent_sigh: '%discount%%% off: %time_left%', 58 | plural: '| %count% hour | %count% hours', 59 | plural_with_placeholders: '| %count% hour %foo% | %count% hours %foo% ', 60 | plural_with_placeholders_inside_tag: "| %count% year with %discount%%% off | %count% years with %discount%%% off", 61 | }; 62 | 63 | return { 64 | getMessage(key: string): string { 65 | return messages[key]; 66 | }, 67 | 68 | getUILanguage(): Locale { 69 | return 'en'; 70 | }, 71 | 72 | getBaseMessage(key: string): string { 73 | return messages[key]; 74 | }, 75 | 76 | getBaseUILanguage(): Locale { 77 | return 'en'; 78 | } 79 | } 80 | })(); 81 | 82 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 83 | const translator = translate.createReactTranslator(i18n, React as any); 84 | 85 | it('translates singular strings', () => { 86 | const message = translator.getMessage('simple'); 87 | expect(message).toEqual(React.Children.toArray([React.createElement('b', null , ['bold']), ' in the text'])); 88 | }); 89 | 90 | it('translates strings with placeholder and single percent sigh', () => { 91 | const message = translator.getMessage('str_with_percent_sigh', { 92 | discount: 60, 93 | time_left: '01:34:12', 94 | span: (chunks: string) => { 95 | return React.createElement('span', null, [chunks]); 96 | }, 97 | }); 98 | 99 | expect(message).toEqual(React.Children.toArray([ 100 | '60', 101 | '% off: ', 102 | React.createElement('span', null, ['01:34:12']), 103 | ])); 104 | }); 105 | 106 | it('translates plural strings', () => { 107 | let message = translator.getPlural('plural', 1, { count: 1 }); 108 | expect(message).toBe('1 hour'); 109 | 110 | message = translator.getPlural('plural', 2, { count: 2 }); 111 | expect(message).toBe('2 hours'); 112 | }); 113 | 114 | it('translates plural strings without parameters', () => { 115 | let message = translator.getPlural('plural', 1); 116 | expect(message).toBe('1 hour'); 117 | 118 | message = translator.getPlural('plural', 2); 119 | expect(message).toBe('2 hours'); 120 | }); 121 | 122 | it('translates plural number with parameters', () => { 123 | let message = translator.getPlural('plural_with_placeholders', 1, { foo: 'bar' }); 124 | expect(message).toBe('1 hour bar'); 125 | 126 | message = translator.getPlural('plural_with_placeholders', 2, { foo: 'bar' }); 127 | expect(message).toBe('2 hours bar'); 128 | }); 129 | 130 | it('translates plural number with parameters and placeholder with percent sigh inside tag', () => { 131 | let message = translator.getPlural('plural_with_placeholders_inside_tag', 1, { 132 | discount: 55, 133 | span: (chunks: string) => { 134 | return React.createElement('span', null, [chunks]); 135 | }, 136 | }); 137 | expect(message).toEqual(React.Children.toArray([ 138 | '1', 139 | ' year with ', 140 | React.createElement('span', null, ['55% off']), 141 | ])); 142 | 143 | message = translator.getPlural('plural_with_placeholders_inside_tag', 2, { 144 | discount: 80, 145 | span: (chunks: string) => { 146 | return React.createElement('span', null, [chunks]); 147 | }, 148 | }); 149 | expect(message).toEqual(React.Children.toArray([ 150 | '2', 151 | ' years with ', 152 | React.createElement('span', null, ['80% off']), 153 | ])); 154 | }); 155 | 156 | }); 157 | }) 158 | -------------------------------------------------------------------------------- /src/formatter.ts: -------------------------------------------------------------------------------- 1 | import { parser } from './parser'; 2 | import { 3 | isTextNode, 4 | isTagNode, 5 | isPlaceholderNode, 6 | isVoidTagNode, 7 | Node, 8 | } from './nodes'; 9 | 10 | /** 11 | * Helper functions used by default to assemble strings from tag nodes 12 | * @param tagName 13 | * @param children 14 | */ 15 | const createStringElement = (tagName: string, children: string): string => { 16 | if (children) { 17 | return `<${tagName}>${children}`; 18 | } 19 | return `<${tagName}/>`; 20 | }; 21 | 22 | interface ValueFunc { 23 | (children: string): string; 24 | } 25 | 26 | export interface ValuesAny { 27 | [key: string]: ValueFunc | unknown, 28 | } 29 | 30 | export interface Values { 31 | [key: string]: ValueFunc | string, 32 | } 33 | 34 | /** 35 | * Creates map with default values for tag converters 36 | */ 37 | const createDefaultValues = (): Values => ({ 38 | p: (children) => createStringElement('p', children), 39 | b: (children) => createStringElement('b', children), 40 | strong: (children) => createStringElement('strong', children), 41 | tt: (children) => createStringElement('tt', children), 42 | s: (children) => createStringElement('s', children), 43 | i: (children) => createStringElement('i', children), 44 | }); 45 | 46 | /** 47 | * Returns prepared error message text. 48 | * 49 | * @param nodeType Node type. 50 | * @param nodeValue Node value. 51 | * @param key String key. 52 | * 53 | * @returns Error message. 54 | */ 55 | const getErrorMessage = (nodeType: string, nodeValue: string, key?: string): string => { 56 | let errorMessage = `Value '${nodeValue}' for '${nodeType}' was not provided`; 57 | if (key) { 58 | errorMessage += ` in string '${key}'`; 59 | } 60 | return errorMessage; 61 | }; 62 | 63 | /** 64 | * This function accepts an AST (abstract syntax tree) which is a result 65 | * of the parser function call, and converts tree nodes into array of strings replacing node 66 | * values with provided values. 67 | * Values is a map with functions or strings, where each key is related to placeholder value 68 | * or tag value 69 | * e.g. 70 | * string "text tag text %placeholder%" is parsed into next AST 71 | * 72 | * [ 73 | * { type: 'text', value: 'text ' }, 74 | * { 75 | * type: 'tag', 76 | * value: 'tag', 77 | * children: [{ type: 'text', value: 'tag text' }], 78 | * }, 79 | * { type: 'text', value: ' ' }, 80 | * { type: 'placeholder', value: 'placeholder' } 81 | * ]; 82 | * 83 | * this AST after format and next values 84 | * 85 | * { 86 | * // here used template strings, but it can be react components as well 87 | * tag: (chunks) => `${chunks}`, 88 | * placeholder: 'placeholder text' 89 | * } 90 | * 91 | * will return next array 92 | * 93 | * [ 'text ', 'tag text', ' ', 'placeholder text' ] 94 | * 95 | * as you can see, was replaced by , and placeholder was replaced by placeholder text 96 | * 97 | * @param key 98 | * @param ast - AST (abstract syntax tree) 99 | * @param values 100 | */ 101 | const format = (key?: string, ast: Node[] = [], values: Values = {}): string[] => { 102 | const result: string[] = []; 103 | 104 | const tmplValues: Values = { ...createDefaultValues(), ...values }; 105 | 106 | let i = 0; 107 | while (i < ast.length) { 108 | const currentNode = ast[i] as Node; 109 | // if current node is text node, there is nothing to change, append value to the result 110 | if (isTextNode(currentNode)) { 111 | result.push(currentNode.value); 112 | } else if (isTagNode(currentNode)) { 113 | const children = [...format(key, currentNode.children, tmplValues)]; 114 | const value = tmplValues[currentNode.value]; 115 | if (value) { 116 | // TODO consider using strong typing 117 | if (typeof value === 'function') { 118 | result.push(value(children.join(''))); 119 | } else { 120 | result.push(value); 121 | } 122 | } else { 123 | throw new Error(getErrorMessage(currentNode.type, currentNode.value, key)) 124 | } 125 | } else if (isVoidTagNode(currentNode)) { 126 | const value = tmplValues[currentNode.value]; 127 | // TODO consider using strong typing 128 | if (value && typeof value === 'string') { 129 | result.push(value); 130 | } else { 131 | throw new Error(getErrorMessage(currentNode.type, currentNode.value, key)) 132 | } 133 | } else if (isPlaceholderNode(currentNode)) { 134 | const value = tmplValues[currentNode.value]; 135 | // TODO consider using strong typing 136 | if (value && typeof value === 'string') { 137 | result.push(value); 138 | } else { 139 | throw new Error(getErrorMessage(currentNode.type, currentNode.value, key)) 140 | } 141 | } 142 | i += 1; 143 | } 144 | 145 | return result; 146 | }; 147 | 148 | /** 149 | * Function gets AST (abstract syntax tree) or string and formats messages, 150 | * replacing values accordingly 151 | * e.g. 152 | * const message = formatter('some text', { 153 | * a: (chunks) => `${chunks}`, 154 | * }); 155 | * console.log(message); // ['some text'] 156 | * 157 | * @param key 158 | * @param message 159 | * @param values 160 | */ 161 | export const formatter = (key?: string, message?: string, values?: ValuesAny): string[] => { 162 | const ast = parser(message); 163 | 164 | const preparedValues: Values = {}; 165 | 166 | // convert values to strings if not a function 167 | if (values) { 168 | Object.keys(values).forEach((key) => { 169 | const value = values[key]; 170 | // TODO consider using strong typing 171 | if (typeof value === 'function') { 172 | preparedValues[key] = value as ValueFunc; 173 | } else { 174 | preparedValues[key] = String(value); 175 | } 176 | }); 177 | } 178 | 179 | return format(key, ast, preparedValues); 180 | }; 181 | -------------------------------------------------------------------------------- /tests/validator.test.ts: -------------------------------------------------------------------------------- 1 | import { AvailableLocales } from '../src/plural'; 2 | import { isTranslationValid } from '../src/validator'; 3 | 4 | describe('validator', () => { 5 | it('returns true if message consists only from string nodes', () => { 6 | const baseMessage = 'test string'; 7 | const targetMessage = 'тестовая строка'; 8 | const locale = AvailableLocales.ru; 9 | 10 | const result = isTranslationValid(baseMessage, targetMessage, locale); 11 | expect(result).toBeTruthy(); 12 | }); 13 | 14 | it('returns true if message has the same tag nodes count', () => { 15 | const baseMessage = 'test string has node'; 16 | const targetMessage = 'тестовая строка с нодой'; 17 | const locale = AvailableLocales.ru; 18 | 19 | const result = isTranslationValid(baseMessage, targetMessage, locale); 20 | expect(result).toBeTruthy(); 21 | }); 22 | 23 | it('returns false if translation has wrong tag', () => { 24 | const baseMessage = 'test string has node'; 25 | const targetMessage = 'тестовая строка с нодой'; 26 | const locale = AvailableLocales.ru; 27 | 28 | const result = isTranslationValid(baseMessage, targetMessage, locale); 29 | expect(result).toBeFalsy(); 30 | }); 31 | 32 | it('returns true if placeholders are same', () => { 33 | const baseMessage = 'test string %placeholder%'; 34 | const targetMessage = 'тестовая строка %placeholder%'; 35 | const locale = AvailableLocales.ru; 36 | 37 | const result = isTranslationValid(baseMessage, targetMessage, locale); 38 | expect(result).toBeTruthy(); 39 | }); 40 | 41 | it('returns false if translators changed placeholder value', () => { 42 | const baseMessage = 'test string %placeholder%'; 43 | const targetMessage = 'тестовая строка %плейсхолдер%'; 44 | const locale = AvailableLocales.ru; 45 | 46 | const result = isTranslationValid(baseMessage, targetMessage, locale); 47 | expect(result).toBeFalsy(); 48 | }); 49 | 50 | it('returns false if target string is not valid', () => { 51 | const baseMessage = 'test string has node'; 52 | const targetMessage = 'тестовая строка с нодой'; 53 | const locale = AvailableLocales.ru; 54 | 55 | const result = isTranslationValid(baseMessage, targetMessage, locale); 56 | expect(result).toBeFalsy(); 57 | }); 58 | 59 | it('returns false if target has same number of nodes, but node is is with another type', () => { 60 | const baseMessage = 'test string has node'; 61 | const targetMessage = 'тестовая строка с нодой %placeholder%'; 62 | const locale = AvailableLocales.ru; 63 | 64 | const result = isTranslationValid(baseMessage, targetMessage, locale); 65 | expect(result).toBeFalsy(); 66 | }); 67 | 68 | it('validates nested nodes', () => { 69 | const baseMessage = 'test string has nested node'; 70 | const targetMessage = 'тестовая строка имеет встроенную ноду'; 71 | const locale = AvailableLocales.ru; 72 | 73 | const result = isTranslationValid(baseMessage, targetMessage, locale); 74 | expect(result).toBeTruthy(); 75 | }); 76 | 77 | it('validates even if there were added text nodes', () => { 78 | const baseMessage = 'test string has tag node'; 79 | const targetMessage = 'тестовая строка имеет тэг ноду и текстовую'; 80 | const locale = AvailableLocales.ru; 81 | 82 | const result = isTranslationValid(baseMessage, targetMessage, locale); 83 | expect(result).toBeTruthy(); 84 | }); 85 | 86 | it('validates even if nodes were rearranged', () => { 87 | const baseMessage = 'b node a node'; 88 | const targetMessage = 'a нода b нода'; 89 | const locale = AvailableLocales.ru; 90 | 91 | const result = isTranslationValid(baseMessage, targetMessage, locale); 92 | expect(result).toBeTruthy(); 93 | }); 94 | 95 | describe('validates plural forms if needed', () => { 96 | test.each([ 97 | { 98 | baseMessage: 'Traffic renews today | Traffic renews in %days% day | Traffic renews in %days% days', 99 | targetMessage: 'Veri bugün yenileniyor | Veri %days% gün içinde yenileniyor', 100 | locale: AvailableLocales.tr, 101 | }, 102 | { 103 | baseMessage: '| Create password, at least %count% character | Create password, at least %count% characters', 104 | targetMessage: '| Créez un mot de passe, contenant au moins %count% caractère | Créez un mot de passe, contenant au moins %count% caractères', 105 | locale: AvailableLocales.fr, 106 | }, 107 | { 108 | baseMessage: 'Traffic renews today | Traffic renews in %days% day | Traffic renews in %days% days', 109 | targetMessage: '通信量は本日更新されます | 通信量は後%days%日で更新されます', 110 | locale: AvailableLocales.ja, 111 | } 112 | ])('$locale - $targetMessage', ({ baseMessage, targetMessage, locale }) => { 113 | const isValid = isTranslationValid(baseMessage, targetMessage, locale); 114 | expect(isValid).toBeTruthy(); 115 | }); 116 | }); 117 | 118 | describe('invalidates translation - error is thrown', () => { 119 | test.each([ 120 | { 121 | baseMessage: 'Traffic renews today | Traffic renews in %days% day | Traffic renews in %days% days', 122 | targetMessage: 'Veri bugün yenileniyor | Veri %days% gün içinde yenileniyor | Veri %days% gün içinde yenileniyor', 123 | locale: AvailableLocales.tr, 124 | expectedError: 'Invalid plural forms', 125 | }, 126 | { 127 | baseMessage: 'An error occurred, please contact us via support@example.com', 128 | targetMessage: 'Bir hata oluştu, lütfen support@example.com adresinden bizimle iletişime geçin', 129 | locale: AvailableLocales.tr, 130 | expectedError: 'String has unbalanced tags', 131 | } 132 | ])('$locale - $targetMessage', ({ baseMessage, targetMessage, locale, expectedError }) => { 133 | expect(() => { 134 | isTranslationValid(baseMessage, targetMessage, locale); 135 | }).toThrow(expectedError); 136 | }); 137 | }); 138 | 139 | describe('invalidates translation - false is returned', () => { 140 | test.each([ 141 | // invalid structure of zero plural form in target message 142 | { 143 | baseMessage: 'Traffic renews today | Traffic renews in %days% day | Traffic renews in %days% days', 144 | targetMessage: 'Veri %days% gün içinde yenileniyor | Veri %days% gün içinde yenileniyor', 145 | locale: AvailableLocales.tr, 146 | }, 147 | // invalid structure of first plural form in target message 148 | { 149 | baseMessage: 'Traffic renews today | Traffic renews in %days% day | Traffic renews in %days% days', 150 | targetMessage: 'Veri bugün yenileniyor | Veri %days% gün %days% içinde yenileniyor', 151 | locale: AvailableLocales.tr, 152 | }, 153 | ])('$locale - $targetMessage', ({ baseMessage, targetMessage, locale }) => { 154 | const isValid = isTranslationValid(baseMessage, targetMessage, locale); 155 | expect(isValid).toBeFalsy(); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /tests/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { parser } from '../src/parser'; 2 | import { Node } from "../src/nodes"; 3 | 4 | describe('parser', () => { 5 | it('parses', () => { 6 | const str = 'String to translate'; 7 | const expectedAst = [{ type: 'text', value: str }]; 8 | expect(parser(str)).toEqual(expectedAst); 9 | }); 10 | 11 | it('parses empty string into empty ast', () => { 12 | const str = ''; 13 | const expectedAst: Node[] = []; 14 | expect(parser(str)).toEqual(expectedAst); 15 | }); 16 | 17 | it('parses text with <', () => { 18 | const str = 'text < abc'; 19 | const expectedAst = [{ type: 'text', value: 'text < abc' }]; 20 | expect(parser(str)).toEqual(expectedAst); 21 | }); 22 | 23 | it('parses tags', () => { 24 | const str = 'String to translate'; 25 | const expectedAst = [ 26 | { 27 | type: 'text', 28 | value: 'String to ', 29 | }, 30 | { 31 | type: 'tag', 32 | value: 'a', 33 | children: [{ type: 'text', value: 'translate' }], 34 | }]; 35 | expect(parser(str)).toEqual(expectedAst); 36 | }); 37 | 38 | it('parses included tags', () => { 39 | const str = 'String with a link link with bold content and some text after'; 40 | const expectedAst = [ 41 | { type: 'text', value: 'String with a link ' }, 42 | { 43 | type: 'tag', 44 | value: 'a', 45 | children: [ 46 | { type: 'text', value: 'link ' }, 47 | { 48 | type: 'tag', 49 | value: 'b', 50 | children: [{ type: 'text', value: 'with bold' }], 51 | }, 52 | { type: 'text', value: ' content' }, 53 | ], 54 | }, 55 | { type: 'text', value: ' and some text after' }, 56 | ]; 57 | 58 | expect(parser(str)).toEqual(expectedAst); 59 | }); 60 | 61 | it('ignores open braces between tags', () => { 62 | const str = '1 < 2'; 63 | const expectedAst = [ 64 | { 65 | type: 'tag', 66 | value: 'abc', 67 | children: [{ type: 'text', value: '1 < 2' }], 68 | }, 69 | ]; 70 | 71 | expect(parser(str)).toEqual(expectedAst); 72 | }); 73 | 74 | it('ignores double open tag braces between tags', () => { 75 | const str = '1 < < 2'; 76 | const expectedAst = [ 77 | { 78 | type: 'tag', 79 | value: 'abc', 80 | children: [{ type: 'text', value: '1 < < 2' }], 81 | }, 82 | ]; 83 | 84 | expect(parser(str)).toEqual(expectedAst); 85 | }); 86 | 87 | it('ignores open tag brace in the end of string', () => { 88 | const str = '1 < 2 <'; 89 | const expectedAst = [ 90 | { 91 | type: 'tag', 92 | value: 'abc', 93 | children: [{ type: 'text', value: '1 < 2' }], 94 | }, 95 | { type: 'text', value: ' <' }, 96 | ]; 97 | 98 | expect(parser(str)).toEqual(expectedAst); 99 | }); 100 | 101 | it('ignores closing braces between tags', () => { 102 | const str = '1 > 2'; 103 | const expectedAst = [ 104 | { 105 | type: 'tag', 106 | value: 'abc', 107 | children: [{ type: 'text', value: '1 > 2' }], 108 | }, 109 | ]; 110 | 111 | expect(parser(str)).toEqual(expectedAst); 112 | }); 113 | 114 | it('ignores open braces in children tags', () => { 115 | const str = 'some text text < in a< 2'; 116 | const expectedAst = [ 117 | { type: 'text', value: 'some text ' }, 118 | { 119 | type: 'tag', 120 | value: 'a', 121 | children: [ 122 | { type: 'text', value: 'text < in a' }, 123 | { type: 'tag', value: 'b', children: [{ type: 'text', value: '< 2' }] }, 124 | ], 125 | }, 126 | ]; 127 | 128 | expect(parser(str)).toEqual(expectedAst); 129 | }); 130 | 131 | it('throws error if tag is not balanced', () => { 132 | const str = 'text '; 133 | expect(() => { 134 | parser(str); 135 | }).toThrow('String has unbalanced tags'); 136 | }); 137 | 138 | it('throws error if tag has attributes:', () => { 139 | const str = 'Reload page to see the log.'; 140 | expect(() => { 141 | parser(str); 142 | }).toThrow('Tags in string should not have attributes'); 143 | }); 144 | 145 | describe('placeholders', () => { 146 | it('parses placeholders in the beginning', () => { 147 | const str = '%replaceable% with text'; 148 | const expectedAst = [{ type: 'placeholder', value: 'replaceable' }, { type: 'text', value: ' with text' }]; 149 | expect(parser(str)).toEqual(expectedAst); 150 | }); 151 | 152 | it('parses placeholders in the end', () => { 153 | const str = 'text with %replaceable%'; 154 | const expectedAst = [{ type: 'text', value: 'text with ' }, { type: 'placeholder', value: 'replaceable' }]; 155 | expect(parser(str)).toEqual(expectedAst); 156 | }); 157 | 158 | it('throws error for unclosed placeholder mark', () => { 159 | const str = 'text with % placeholder mark'; 160 | expect(() => parser(str)).toThrow("Unclosed placeholder marker '%' in string"); 161 | }); 162 | 163 | it('throws error for unbalanced placeholder mark', () => { 164 | const str = 'text with %replaceable% mark % text end'; 165 | expect(() => parser(str)).toThrow("Unclosed placeholder marker '%' in string"); 166 | }); 167 | 168 | it('double placeholder marks are considered as escape for %', () => { 169 | const str = 'text %% some %replaceable%'; 170 | const expectedAst = [ 171 | { type: 'text', value: 'text % some ' }, 172 | { type: 'placeholder', value: 'replaceable' }, 173 | ]; 174 | expect(parser(str)).toEqual(expectedAst); 175 | }); 176 | 177 | it('double placeholder marks are considered as escape for % 2', () => { 178 | const str = 'text %% some'; 179 | const expectedAst = [ 180 | { type: 'text', value: 'text % some' }, 181 | ]; 182 | expect(parser(str)).toEqual(expectedAst); 183 | }); 184 | 185 | it('parses nested placeholders', () => { 186 | const str = 'text with tag with %replaceable%'; 187 | const expectedAst = [ 188 | { type: 'text', value: 'text with ' }, 189 | { 190 | type: 'tag', 191 | value: 'a', 192 | children: [ 193 | { 194 | type: 'text', 195 | value: 'tag with ', 196 | }, { 197 | type: 'placeholder', 198 | value: 'replaceable', 199 | }], 200 | }, 201 | ]; 202 | expect(parser(str)).toEqual(expectedAst); 203 | }); 204 | }); 205 | 206 | describe('void tags', () => { 207 | it('parses void tags as string', () => { 208 | const str = 'cat float'; 209 | const expectedAst = [ 210 | { type: 'text', value: 'cat ' }, 211 | { type: 'void_tag', value: 'img' }, 212 | { type: 'text', value: ' float' }, 213 | ]; 214 | expect(parser(str)).toEqual(expectedAst); 215 | }); 216 | 217 | it('parses void tags even if they have spaces', () => { 218 | const str = 'cat float'; 219 | const expectedAst = [ 220 | { type: 'text', value: 'cat ' }, 221 | { type: 'void_tag', value: 'img' }, 222 | { type: 'text', value: ' float' }, 223 | ]; 224 | expect(parser(str)).toEqual(expectedAst); 225 | }); 226 | 227 | it('parses void tags as string with neighbors', () => { 228 | const str = 'cat '; 229 | const expectedAst = [ 230 | { type: 'text', value: 'cat ' }, 231 | { 232 | type: 'tag', 233 | value: 'a', 234 | children: [{ type: 'void_tag', value: 'img' }], 235 | }, 236 | ]; 237 | expect(parser(str)).toEqual(expectedAst); 238 | }); 239 | }); 240 | }); 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AdGuard Translate 2 | 3 | Simple internationalization library with React integration 4 | 5 | * [Installation](#installation) 6 | * [npm](#npm) 7 | * [Yarn](#yarn) 8 | * [Usage](#usage) 9 | * [Messages format](#messages-format) 10 | * [Placeholders](#placeholders) 11 | * [Tags support](#tags-support) 12 | * [Default list of tags](#default-list-of-tags) 13 | * [Plural strings](#plural-strings) 14 | * [translate](#translate) 15 | * [createTranslator](#create-translator) 16 | * [createReactTranslator](#create-react-translator) 17 | * [validator](#validator) 18 | * [isTranslationValid](#is-translation-valid) 19 | * [isPluralFormValid](#is-plural-form-valid) 20 | * [API](#api) 21 | * [createTranslator](#api-create-translator) 22 | * [createReactTranslator](#api-create-react-translator) 23 | * [getMessage](#api-get-message) 24 | * [getPlural](#api-get-plural) 25 | * [isTranslationValid](#api-is-translation-valid) 26 | * [isPluralFormValid](#api-is-plural-form-valid) 27 | * [Development](#development) 28 | * [Build](#build) 29 | * [Lint](#lint) 30 | * [Test](#test) 31 | * [Docs](#docs) 32 | * [TODO](#todo) 33 | * [License](#license) 34 | 35 | ## Installation 36 | 37 | ### npm 38 | 39 | ``` 40 | npm install @adguard/translate 41 | ``` 42 | 43 | ### Yarn 44 | 45 | ``` 46 | yarn add @adguard/translate 47 | ``` 48 | 49 | ## Usage 50 | 51 | ### Messages format 52 | 53 | Library supports messages with placeholders, tags and plural forms 54 | 55 | #### Placeholders 56 | 57 | Placeholders should be wrapped in `%` mark 58 | 59 | e.g. 60 | 61 | ``` 62 | "agreement_consent": { 63 | "message": "Servers number %count%", 64 | } 65 | ``` 66 | 67 | Single `%` marks must be escaped by another `%` mark 68 | 69 | e.g. 70 | 71 | ``` 72 | "discount": { 73 | "message": "You have 50%% dicount", 74 | } 75 | "discount_placeholder" { 76 | "message": "You have %discount_value%%% dicount", 77 | } 78 | ``` 79 | 80 | 81 | #### Tags support 82 | 83 | Library supports open/close tags, and their values should be provided in the `translate` method 84 | 85 | e.g. 86 | 87 | ``` 88 | link to the text 89 | ``` 90 | 91 | and void tags 92 | 93 | e.g. 94 | 95 | ``` 96 | 97 | ``` 98 | 99 | ##### Default list of tags 100 | 101 | Next tags are not required in the `translate`, because they are provided by default 102 | 103 | ``` 104 | ,

, , , , , 105 | ``` 106 | 107 | #### Plural strings 108 | 109 | Library supports plural strings translation. 110 | 111 | e.g. 112 | 113 | ``` 114 | Нет серверов | %count% сервер | %count% сервера | %count% серверов 115 | ``` 116 | 117 | Plural strings should follow simple rules. 118 | 119 | 1. Plural forms should be divided by `|`. 120 | 2. Plural forms count should correspond to the language plural forms count ([table](https://github.com/translate/l10n-guide/blob/master/docs/l10n/pluralforms.rst)) + 1 (zero form). 121 | 3. If first plural form is omitted, for the zero form you'll get empty string 122 | 123 | ``` 124 | | %count% сервер | %count% сервера | %count% серверов 125 | ``` 126 | 127 | ### translate 128 | 129 | ``` 130 | // import library 131 | import { translate } from '@adguard/translate' 132 | 133 | // create i18n object which implements I18nInterface: 134 | interface I18nInterface { 135 | /** 136 | * Returns message by key for current locale 137 | * @param key 138 | */ 139 | getMessage(key: string): string; 140 | 141 | /** 142 | * Returns current locale code 143 | * Locale codes should be in the list of `Locale`s 144 | */ 145 | getUILanguage(): Locale; 146 | 147 | /** 148 | * Returns base locale message 149 | * @param key 150 | */ 151 | getBaseMessage(key: string): string; 152 | 153 | /** 154 | * Returns base locale code 155 | */ 156 | getBaseUILanguage(): Locale; 157 | } 158 | 159 | // in the browser extension it will be "browser.i18n" 160 | 161 | // create translate function 162 | const translator = translate.createTranslator(i18n) 163 | 164 | // e.g. 165 | // string to translate: 166 | // "agreement_consent": { 167 | // "message": "You agree to our EULA", 168 | // } 169 | ``` 170 | 171 | #### createTranslator 172 | 173 | This method can be used to translate simple strings and Vue template strings 174 | 175 | ``` 176 | const translator = translate.createTranslator(browser.i18n); 177 | const translatedString = translator.getMessage('agreement_consent', { 178 | eula: (chunks) => ``, 179 | }); 180 | 181 | console.log(translatedString); // 182 | ``` 183 | 184 | #### createReactTranslator 185 | 186 | ``` 187 | const reactTranslator = translate.createReactTranslator(browser.i18n, React); 188 | 189 |

190 | {reactTranslator.getMessage('agreement_consent', { 191 | eula: (chunks) => ( 192 | 198 | ), 199 | })} 200 |
201 | ``` 202 | 203 | ### validator 204 | 205 | ``` 206 | // import library 207 | import { validator } from '@adguard/translate' 208 | ``` 209 | 210 | #### isTranslationValid 211 | 212 | ``` 213 | const baseMessage = 'test string has node'; 214 | const targetMessage = 'тестовая строка с нодой'; 215 | 216 | validator.isTranslationValid(baseMessage, targetMessage); // true 217 | ``` 218 | 219 | #### isPluralFormValid 220 | 221 | ``` 222 | validator.isPluralFormValid(%count% серверов | %count% сервер | %count% сервера | %count% серверов, 'ru', "servers_count"); // true, all 4 plural forms are provided 223 | 224 | validator.isPluralFormValid(%count% сервера | %count% серверов, 'ru', "servers_count"); // false, ru locale awaits for 4 plural forms provided 225 | ``` 226 | 227 | ### API 228 | 229 | #### createTranslator 230 | 231 | ``` 232 | /** 233 | * Creates translator instance strings, by default for simple strings 234 | * @param i18n - function which returns translated message by key 235 | * @param messageConstructor - function that will collect messages 236 | * @param values - map with default values for tag converters 237 | */ 238 | const createTranslator = ( 239 | i18n: I18nInterface, 240 | messageConstructor?: MessageConstructorInterface, 241 | values?: ValuesAny 242 | ): Translator 243 | ``` 244 | 245 | #### createReactTranslator 246 | 247 | ``` 248 | /** 249 | * Creates translation function for strings used in the React components 250 | * We do not import React directly, because translator module can be used 251 | * in the modules without React too 252 | * 253 | * 254 | * @param i18n - object with methods which get translated message by key and return current locale 255 | * @param React - instance of react library 256 | */ 257 | const createReactTranslator = (i18n: I18nInterface, React: ReactCustom): Translator 258 | ``` 259 | 260 | #### getMessage 261 | ``` 262 | /** 263 | * Retrieves message and translates it, substituting parameters where necessary 264 | * @param key - translation message key 265 | * @param params - values used to substitute placeholders and tags 266 | */ 267 | public getMessage(key: string, params: ValuesAny = {}): T { 268 | ``` 269 | 270 | #### getPlural 271 | ``` 272 | /** 273 | * Retrieves correct plural form and translates it 274 | * @param key - translation message key 275 | * @param number - plural form number 276 | * @param params - values used to substitute placeholders or tags if necessary, 277 | * if params has "count" property it will be overridden by number (plural form number) 278 | */ 279 | public getPlural(key: string, number: number, params: ValuesAny = {}): T { 280 | ``` 281 | 282 | #### isTranslationValid 283 | 284 | ```js 285 | /** 286 | * Validates translation against base string by AST (abstract syntax tree) structure. 287 | * 288 | * @param baseMessage Base message. 289 | * @param translatedMessage Translated message. 290 | * @param locale Locale of `translatedMessage`. 291 | * 292 | * @returns True if translated message is valid, false otherwise: 293 | * - if base message has no plural forms, it will return true if AST structures are same; 294 | * - if base message has plural forms, first of all 295 | * the function checks if the number of plural forms is correct for the `locale`, 296 | * and then it validates AST plural forms structures for base and translated messages. 297 | * 298 | * @throws Error for invalid tags in base or translated messages, 299 | * or if translated message has invalid plural forms. 300 | */ 301 | const isTranslationValid = (baseMessage: string, translatedMessage: string, locale: Locale): boolean 302 | ``` 303 | 304 | #### isPluralFormValid 305 | 306 | ```js 307 | /** 308 | * Checks if plural forms are valid. 309 | * 310 | * @param targetStr Translated message with plural forms. 311 | * @param locale Locale. 312 | * @param key Optional, message key, used for clearer log message. 313 | * 314 | * @returns True if plural forms are valid, false otherwise. 315 | */ 316 | const isPluralFormValid = (targetStr: string, locale: Locale, key: string): boolean 317 | ``` 318 | 319 | ## Development 320 | 321 | Use yarn to build the library 322 | 323 | ### Build 324 | 325 | To build the library run: 326 | 327 | ``` 328 | yarn build 329 | ``` 330 | 331 | Build result would be in the `dist` directory 332 | 333 | ### Lint 334 | 335 | To check lint errors run in terminal: 336 | 337 | ``` 338 | yarn lint 339 | ``` 340 | 341 | ### Test 342 | 343 | The library uses jest for running unit-tests. To launch the tests, run the following command in the terminal: 344 | 345 | ``` 346 | yarn test 347 | ``` 348 | 349 | ### Docs 350 | 351 | To build documentation, run the following command in the terminal: 352 | 353 | ``` 354 | yarn docs 355 | ``` 356 | 357 | ### TODO 358 | 359 | - [ ] Create Vue plugin 360 | - [ ] Add utility to check if code contains unused or redundant translation messages 361 | 362 | ### License 363 | 364 | MIT 365 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | tagNode, 3 | textNode, 4 | isNode, 5 | placeholderNode, 6 | voidTagNode, 7 | Node, 8 | } from './nodes'; 9 | 10 | enum STATE { 11 | /** 12 | * Parser function switches to the text state when parses simple text, 13 | * or content between open and close tags 14 | */ 15 | TEXT = 'text', 16 | 17 | /** 18 | * Parser function switches to the tag state when meets open tag brace ("<"), and switches back, 19 | * when meets closing tag brace (">") 20 | */ 21 | TAG = 'tag', 22 | 23 | /** 24 | * Parser function switches to the placeholder state when meets in the text 25 | * open placeholders brace ("{") and switches back to the text state, 26 | * when meets close placeholder brace ("}") 27 | */ 28 | PLACEHOLDER = 'placeholder', 29 | } 30 | 31 | const CONTROL_CHARS = { 32 | TAG_OPEN_BRACE: '<', 33 | TAG_CLOSE_BRACE: '>', 34 | CLOSING_TAG_MARK: '/', 35 | PLACEHOLDER_MARK: '%', 36 | }; 37 | 38 | interface Context { 39 | /** 40 | * Stack is used to keep and search nested tag nodes 41 | */ 42 | stack: (Node | string)[], 43 | 44 | /** 45 | * Result is stack where function allocates nodes 46 | */ 47 | result: Node[], 48 | 49 | /** 50 | * Accumulated text value 51 | */ 52 | text: string, 53 | 54 | /** 55 | * Saves index of the last state change from the text state, 56 | * used to restore parsed text if we moved into other state wrongly 57 | */ 58 | lastTextStateChangeIdx: number, 59 | 60 | /** 61 | * Current char 62 | */ 63 | currChar?: string, 64 | 65 | /** 66 | * Current char index 67 | */ 68 | currIdx: number, 69 | 70 | /** 71 | * Accumulated placeholder value 72 | */ 73 | placeholder: string, 74 | 75 | /** 76 | * Parsed string 77 | */ 78 | str: string, 79 | 80 | /** 81 | * Accumulated tag value 82 | */ 83 | tag: string, 84 | } 85 | 86 | /** 87 | * Checks if text length is enough to create text node 88 | * If text node created, then if stack is not empty it is pushed into stack, 89 | * otherwise into result 90 | * @param context 91 | */ 92 | const createTextNodeIfPossible = (context: Context) => { 93 | const { text } = context; 94 | 95 | if (text.length > 0) { 96 | const node = textNode(text); 97 | if (context.stack.length > 0) { 98 | context.stack.push(node); 99 | } else { 100 | context.result.push(node); 101 | } 102 | } 103 | 104 | context.text = ''; 105 | }; 106 | 107 | /** 108 | * Checks if lastFromStack tag has any attributes 109 | * @param lastFromStack 110 | */ 111 | const hasAttributes = (lastFromStack: string) => { 112 | // e.g. "a class" or "a href='#'" 113 | const tagStrParts = lastFromStack.split(' '); 114 | return tagStrParts.length > 1; 115 | }; 116 | 117 | interface StateHandlerFunc { 118 | (context: Context): STATE; 119 | } 120 | 121 | /** 122 | * Handles text state 123 | */ 124 | const textStateHandler: StateHandlerFunc = (context: Context): STATE => { 125 | const { currChar, currIdx } = context; 126 | 127 | // switches to the tag state 128 | if (currChar === CONTROL_CHARS.TAG_OPEN_BRACE) { 129 | context.lastTextStateChangeIdx = currIdx; 130 | return STATE.TAG; 131 | } 132 | 133 | // switches to the placeholder state 134 | if (currChar === CONTROL_CHARS.PLACEHOLDER_MARK) { 135 | context.lastTextStateChangeIdx = currIdx; 136 | return STATE.PLACEHOLDER; 137 | } 138 | 139 | // remains in the text state 140 | context.text += currChar; 141 | return STATE.TEXT; 142 | }; 143 | 144 | /** 145 | * Handles placeholder state 146 | * @param context 147 | */ 148 | const placeholderStateHandler: StateHandlerFunc = (context: Context): STATE => { 149 | const { 150 | currChar, 151 | currIdx, 152 | lastTextStateChangeIdx, 153 | placeholder, 154 | stack, 155 | result, 156 | str, 157 | } = context; 158 | 159 | if (currChar === CONTROL_CHARS.PLACEHOLDER_MARK) { 160 | // if distance between current index and last state change equal to 1, 161 | // it means that placeholder mark was escaped by itself e.g. "%%", 162 | // so we return to the text state 163 | if (currIdx - lastTextStateChangeIdx === 1) { 164 | context.text += str.substring(lastTextStateChangeIdx, currIdx); 165 | return STATE.TEXT; 166 | } 167 | 168 | createTextNodeIfPossible(context); 169 | const node = placeholderNode(placeholder); 170 | 171 | // push node to the appropriate stack 172 | if (stack.length > 0) { 173 | stack.push(node); 174 | } else { 175 | result.push(node); 176 | } 177 | 178 | context.placeholder = ''; 179 | return STATE.TEXT; 180 | } 181 | 182 | context.placeholder += currChar; 183 | return STATE.PLACEHOLDER; 184 | }; 185 | 186 | /** 187 | * Switches current state to the tag state and returns tag state handler 188 | */ 189 | const tagStateHandler: StateHandlerFunc = (context: Context): STATE => { 190 | const { 191 | currChar, 192 | text, 193 | stack, 194 | result, 195 | lastTextStateChangeIdx, 196 | currIdx, 197 | str, 198 | } = context; 199 | 200 | let { tag } = context; 201 | 202 | // if found tag end ">" 203 | if (currChar === CONTROL_CHARS.TAG_CLOSE_BRACE) { 204 | // if the tag is close tag e.g. 205 | if (tag.indexOf(CONTROL_CHARS.CLOSING_TAG_MARK) === 0) { 206 | // remove slash from tag 207 | tag = tag.substring(1); 208 | 209 | let children: Node[] = []; 210 | if (text.length > 0) { 211 | children.push(textNode(text)); 212 | context.text = ''; 213 | } 214 | 215 | let pairTagFound = false; 216 | // looking for the pair to the close tag 217 | while (!pairTagFound && stack.length > 0) { 218 | const lastFromStack = stack.pop() as (Node | string); 219 | // if tag from stack equal to close tag 220 | if (lastFromStack === tag) { 221 | // create tag node 222 | const node = tagNode(tag, children); 223 | // and add it to the appropriate stack 224 | if (stack.length > 0) { 225 | stack.push(node); 226 | } else { 227 | result.push(node); 228 | } 229 | children = []; 230 | pairTagFound = true; 231 | } else if (isNode(lastFromStack)) { 232 | // add nodes between close tag and open tag to the children 233 | children.unshift(lastFromStack as Node); 234 | } else { 235 | if (typeof lastFromStack === 'string' && hasAttributes(lastFromStack)) { 236 | throw new Error(`Tags in string should not have attributes: ${str}`); 237 | } else { 238 | throw new Error(`String has unbalanced tags: ${str}`); 239 | } 240 | } 241 | if (stack.length === 0 && children.length > 0) { 242 | throw new Error(`String has unbalanced tags: ${str}`); 243 | } 244 | } 245 | context.tag = ''; 246 | return STATE.TEXT; 247 | } 248 | 249 | // if the tag is void tag e.g. 250 | if (tag.lastIndexOf(CONTROL_CHARS.CLOSING_TAG_MARK) === tag.length - 1) { 251 | tag = tag.substring(0, tag.length - 1); 252 | createTextNodeIfPossible(context); 253 | const node = voidTagNode(tag); 254 | // add node to the appropriate stack 255 | if (stack.length > 0) { 256 | stack.push(node); 257 | } else { 258 | result.push(node); 259 | } 260 | context.tag = ''; 261 | return STATE.TEXT; 262 | } 263 | 264 | createTextNodeIfPossible(context); 265 | stack.push(tag); 266 | context.tag = ''; 267 | return STATE.TEXT; 268 | } 269 | 270 | // If we meet open tag "<" it means that we wrongly moved into tag state 271 | if (currChar === CONTROL_CHARS.TAG_OPEN_BRACE) { 272 | context.text += str.substring(lastTextStateChangeIdx, currIdx); 273 | context.lastTextStateChangeIdx = currIdx; 274 | context.tag = ''; 275 | return STATE.TAG; 276 | } 277 | 278 | context.tag += currChar; 279 | return STATE.TAG; 280 | }; 281 | 282 | /** 283 | * Parses string into AST (abstract syntax tree) and returns it 284 | * e.g. 285 | * parse("String to translate") -> 286 | * ``` 287 | * [ 288 | * { type: 'text', value: 'String to ' }, 289 | * { type: 'tag', value: 'a', children: [{ type: 'text', value: 'translate' }] } 290 | * ]; 291 | * ``` 292 | * Empty string is parsed into empty AST (abstract syntax tree): "[]" 293 | * 294 | * @param str Message in simplified ICU like syntax without plural support. 295 | * 296 | * @returns AST representation of the input string. 297 | * 298 | * @throws Error if tags have attributes or string has unbalanced tags or placeholder marker '%' is unclosed. 299 | */ 300 | export const parser = (str = ''): Node[] => { 301 | const context: Context = { 302 | /** 303 | * Stack is used to keep and search nested tag nodes 304 | */ 305 | stack: [], 306 | 307 | /** 308 | * Result is stack where function allocates nodes 309 | */ 310 | result: [], 311 | 312 | /** 313 | * Current char index 314 | */ 315 | currIdx: 0, 316 | 317 | /** 318 | * Saves index of the last state change from the text state, 319 | * used to restore parsed text if we moved into other state wrongly 320 | */ 321 | lastTextStateChangeIdx: 0, 322 | 323 | /** 324 | * Accumulated tag value 325 | */ 326 | tag: '', 327 | 328 | /** 329 | * Accumulated text value 330 | */ 331 | text: '', 332 | 333 | /** 334 | * Accumulated placeholder value 335 | */ 336 | placeholder: '', 337 | 338 | /** 339 | * Parsed string 340 | */ 341 | str, 342 | }; 343 | 344 | const STATE_HANDLERS = { 345 | [STATE.TEXT]: textStateHandler, 346 | [STATE.PLACEHOLDER]: placeholderStateHandler, 347 | [STATE.TAG]: tagStateHandler, 348 | }; 349 | 350 | // Start from text state 351 | let currentState = STATE.TEXT; 352 | 353 | while (context.currIdx < str.length) { 354 | context.currChar = str[context.currIdx]; 355 | const currentStateHandler: StateHandlerFunc = STATE_HANDLERS[currentState]; 356 | currentState = currentStateHandler(context); 357 | context.currIdx += 1; 358 | } 359 | 360 | const { 361 | result, 362 | text, 363 | stack, 364 | lastTextStateChangeIdx, 365 | } = context; 366 | 367 | // Means that placeholder nodes were not closed 368 | if (currentState === STATE.PLACEHOLDER) { 369 | throw new Error(`Unclosed placeholder marker '%' in string: ${str}`); 370 | } 371 | 372 | // Means that tag node were not closed, so we consider them as text 373 | if (currentState !== STATE.TEXT) { 374 | const restText = str.substring(lastTextStateChangeIdx); 375 | if ((restText + text).length > 0) { 376 | result.push(textNode(text + restText)); 377 | } 378 | } else { 379 | // eslint-disable-next-line no-lonely-if 380 | if (text.length > 0) { 381 | result.push(textNode(text)); 382 | } 383 | } 384 | 385 | if (stack.length > 0) { 386 | throw new Error(`String has unbalanced tags: ${context.str}`); 387 | } 388 | 389 | return result; 390 | }; 391 | -------------------------------------------------------------------------------- /src/plural.ts: -------------------------------------------------------------------------------- 1 | export enum AvailableLocales { 2 | az = 'az', 3 | bo = 'bo', 4 | dz = 'dz', 5 | id = 'id', 6 | ja = 'ja', 7 | jv = 'jv', 8 | ka = 'ka', 9 | km = 'km', 10 | kn = 'kn', 11 | ko = 'ko', 12 | ms = 'ms', 13 | th = 'th', 14 | tr = 'tr', 15 | vi = 'vi', 16 | zh = 'zh', 17 | zh_cn = 'zh_cn', 18 | zh_tw = 'zh_tw', 19 | af = 'af', 20 | bn = 'bn', 21 | bg = 'bg', 22 | ca = 'ca', 23 | da = 'da', 24 | de = 'de', 25 | el = 'el', 26 | en = 'en', 27 | eo = 'eo', 28 | es = 'es', 29 | et = 'et', 30 | eu = 'eu', 31 | fa = 'fa', 32 | fi = 'fi', 33 | fo = 'fo', 34 | fur = 'fur', 35 | fy = 'fy', 36 | gl = 'gl', 37 | gu = 'gu', 38 | ha = 'ha', 39 | he = 'he', 40 | hu = 'hu', 41 | is = 'is', 42 | it = 'it', 43 | ku = 'ku', 44 | lb = 'lb', 45 | ml = 'ml', 46 | mn = 'mn', 47 | mr = 'mr', 48 | nah = 'nah', 49 | nb = 'nb', 50 | ne = 'ne', 51 | nl = 'nl', 52 | nn = 'nn', 53 | no = 'no', 54 | oc = 'oc', 55 | om = 'om', 56 | or = 'or', 57 | pa = 'pa', 58 | pap = 'pap', 59 | ps = 'ps', 60 | pt = 'pt', 61 | pt_pt = 'pt_pt', 62 | pt_br = 'pt_br', 63 | so = 'so', 64 | sq = 'sq', 65 | sv = 'sv', 66 | sw = 'sw', 67 | ta = 'ta', 68 | te = 'te', 69 | tk = 'tk', 70 | ur = 'ur', 71 | zu = 'zu', 72 | am = 'am', 73 | bh = 'bh', 74 | fil = 'fil', 75 | fr = 'fr', 76 | gun = 'gun', 77 | hi = 'hi', 78 | hy = 'hy', 79 | ln = 'ln', 80 | mg = 'mg', 81 | nso = 'nso', 82 | xbr = 'xbr', 83 | ti = 'ti', 84 | wa = 'wa', 85 | be = 'be', 86 | bs = 'bs', 87 | hr = 'hr', 88 | ru = 'ru', 89 | sr = 'sr', 90 | uk = 'uk', 91 | cs = 'cs', 92 | sk = 'sk', 93 | ga = 'ga', 94 | lt = 'lt', 95 | sl = 'sl', 96 | mk = 'mk', 97 | mt = 'mt', 98 | lv = 'lv', 99 | pl = 'pl', 100 | cy = 'cy', 101 | ro = 'ro', 102 | ar = 'ar', 103 | sr_latn = 'sr_latn' 104 | } 105 | 106 | export type Locale = keyof typeof AvailableLocales; 107 | 108 | const getPluralFormId = (locale: Locale, number: number): number => { 109 | if (number === 0) { 110 | return 0; 111 | } 112 | 113 | const slavNum = ((number % 10 === 1) && (number % 100 !== 11)) 114 | ? 1 115 | : ( 116 | ((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) 117 | || (number % 100 >= 20)) 118 | ) 119 | ? 2 120 | : 3); 121 | const supportedForms: Record = { 122 | [AvailableLocales.az]: 1, 123 | [AvailableLocales.bo]: 1, 124 | [AvailableLocales.dz]: 1, 125 | [AvailableLocales.id]: 1, 126 | [AvailableLocales.ja]: 1, 127 | [AvailableLocales.jv]: 1, 128 | [AvailableLocales.ka]: 1, 129 | [AvailableLocales.km]: 1, 130 | [AvailableLocales.kn]: 1, 131 | [AvailableLocales.ko]: 1, 132 | [AvailableLocales.ms]: 1, 133 | [AvailableLocales.th]: 1, 134 | [AvailableLocales.tr]: 1, 135 | [AvailableLocales.vi]: 1, 136 | [AvailableLocales.zh]: 1, 137 | [AvailableLocales.zh_tw]: 1, 138 | [AvailableLocales.zh_cn]: 1, 139 | 140 | [AvailableLocales.af]: (number === 1) ? 1 : 2, 141 | [AvailableLocales.bn]: (number === 1) ? 1 : 2, 142 | [AvailableLocales.bg]: (number === 1) ? 1 : 2, 143 | [AvailableLocales.ca]: (number === 1) ? 1 : 2, 144 | [AvailableLocales.da]: (number === 1) ? 1 : 2, 145 | [AvailableLocales.de]: (number === 1) ? 1 : 2, 146 | [AvailableLocales.el]: (number === 1) ? 1 : 2, 147 | [AvailableLocales.en]: (number === 1) ? 1 : 2, 148 | [AvailableLocales.eo]: (number === 1) ? 1 : 2, 149 | [AvailableLocales.es]: (number === 1) ? 1 : 2, 150 | [AvailableLocales.et]: (number === 1) ? 1 : 2, 151 | [AvailableLocales.eu]: (number === 1) ? 1 : 2, 152 | [AvailableLocales.fa]: (number === 1) ? 1 : 2, 153 | [AvailableLocales.fi]: (number === 1) ? 1 : 2, 154 | [AvailableLocales.fo]: (number === 1) ? 1 : 2, 155 | [AvailableLocales.fur]: (number === 1) ? 1 : 2, 156 | [AvailableLocales.fy]: (number === 1) ? 1 : 2, 157 | [AvailableLocales.gl]: (number === 1) ? 1 : 2, 158 | [AvailableLocales.gu]: (number === 1) ? 1 : 2, 159 | [AvailableLocales.ha]: (number === 1) ? 1 : 2, 160 | [AvailableLocales.he]: (number === 1) ? 1 : 2, 161 | [AvailableLocales.hu]: (number === 1) ? 1 : 2, 162 | [AvailableLocales.is]: (number === 1) ? 1 : 2, 163 | [AvailableLocales.it]: (number === 1) ? 1 : 2, 164 | [AvailableLocales.ku]: (number === 1) ? 1 : 2, 165 | [AvailableLocales.lb]: (number === 1) ? 1 : 2, 166 | [AvailableLocales.ml]: (number === 1) ? 1 : 2, 167 | [AvailableLocales.mn]: (number === 1) ? 1 : 2, 168 | [AvailableLocales.mr]: (number === 1) ? 1 : 2, 169 | [AvailableLocales.nah]: (number === 1) ? 1 : 2, 170 | [AvailableLocales.nb]: (number === 1) ? 1 : 2, 171 | [AvailableLocales.ne]: (number === 1) ? 1 : 2, 172 | [AvailableLocales.nl]: (number === 1) ? 1 : 2, 173 | [AvailableLocales.nn]: (number === 1) ? 1 : 2, 174 | [AvailableLocales.no]: (number === 1) ? 1 : 2, 175 | [AvailableLocales.oc]: (number === 1) ? 1 : 2, 176 | [AvailableLocales.om]: (number === 1) ? 1 : 2, 177 | [AvailableLocales.or]: (number === 1) ? 1 : 2, 178 | [AvailableLocales.pa]: (number === 1) ? 1 : 2, 179 | [AvailableLocales.pap]: (number === 1) ? 1 : 2, 180 | [AvailableLocales.ps]: (number === 1) ? 1 : 2, 181 | [AvailableLocales.pt]: (number === 1) ? 1 : 2, 182 | [AvailableLocales.pt_pt]: (number === 1) ? 1 : 2, 183 | [AvailableLocales.pt_br]: (number === 1) ? 1 : 2, 184 | [AvailableLocales.so]: (number === 1) ? 1 : 2, 185 | [AvailableLocales.sq]: (number === 1) ? 1 : 2, 186 | [AvailableLocales.sv]: (number === 1) ? 1 : 2, 187 | [AvailableLocales.sw]: (number === 1) ? 1 : 2, 188 | [AvailableLocales.ta]: (number === 1) ? 1 : 2, 189 | [AvailableLocales.te]: (number === 1) ? 1 : 2, 190 | [AvailableLocales.tk]: (number === 1) ? 1 : 2, 191 | [AvailableLocales.ur]: (number === 1) ? 1 : 2, 192 | [AvailableLocales.zu]: (number === 1) ? 1 : 2, 193 | 194 | // how it works with 0? 195 | [AvailableLocales.am]: ((number === 0) || (number === 1)) ? 0 : 1, 196 | [AvailableLocales.bh]: ((number === 0) || (number === 1)) ? 0 : 1, 197 | [AvailableLocales.fil]: ((number === 0) || (number === 1)) ? 0 : 1, 198 | [AvailableLocales.fr]: ((number === 0) || (number >= 2)) ? 2 : 1, 199 | [AvailableLocales.gun]: ((number === 0) || (number === 1)) ? 0 : 1, 200 | [AvailableLocales.hi]: ((number === 0) || (number === 1)) ? 0 : 1, 201 | [AvailableLocales.hy]: ((number === 0) || (number === 1)) ? 0 : 1, 202 | [AvailableLocales.ln]: ((number === 0) || (number === 1)) ? 0 : 1, 203 | [AvailableLocales.mg]: ((number === 0) || (number === 1)) ? 0 : 1, 204 | [AvailableLocales.nso]: ((number === 0) || (number === 1)) ? 0 : 1, 205 | [AvailableLocales.xbr]: ((number === 0) || (number === 1)) ? 0 : 1, 206 | [AvailableLocales.ti]: ((number === 0) || (number === 1)) ? 0 : 1, 207 | [AvailableLocales.wa]: ((number === 0) || (number === 1)) ? 0 : 1, 208 | 209 | [AvailableLocales.be]: slavNum, 210 | [AvailableLocales.bs]: slavNum, 211 | [AvailableLocales.hr]: slavNum, 212 | [AvailableLocales.ru]: slavNum, 213 | [AvailableLocales.sr]: slavNum, 214 | [AvailableLocales.sr_latn]: slavNum, 215 | [AvailableLocales.uk]: slavNum, 216 | 217 | [AvailableLocales.cs]: (number === 1) ? 1 : (((number >= 2) && (number <= 4)) ? 2 : 3), 218 | [AvailableLocales.sk]: (number === 1) ? 1 : (((number >= 2) && (number <= 4)) ? 2 : 3), 219 | [AvailableLocales.ga]: (number === 1) ? 1 : ((number === 2) ? 2 : 3), 220 | [AvailableLocales.lt]: ((number % 10 === 1) && (number % 100 !== 11)) 221 | ? 1 222 | : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) 223 | ? 2 224 | : 3), 225 | [AvailableLocales.sl]: (number % 100 === 1) 226 | ? 1 227 | : ((number % 100 === 2) 228 | ? 2 229 | : (((number % 100 === 3) || (number % 100 === 4)) 230 | ? 3 231 | : 4)), 232 | [AvailableLocales.mk]: (number % 10 === 1) ? 1 : 2, 233 | [AvailableLocales.mt]: (number === 1) 234 | ? 1 235 | : (((number === 0) || ((number % 100 > 1) && (number % 100 < 11))) 236 | ? 2 237 | : (((number % 100 > 10) && (number % 100 < 20)) 238 | ? 3 239 | : 4)), 240 | [AvailableLocales.lv]: (number === 0) 241 | ? 0 242 | : (((number % 10 === 1) && (number % 100 !== 11)) 243 | ? 1 244 | : 2), 245 | [AvailableLocales.pl]: (number === 1) 246 | ? 1 247 | : ( 248 | ((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) 249 | || (number % 100 > 14)) 250 | ) 251 | ? 2 252 | : 3), 253 | [AvailableLocales.cy]: (number === 1) 254 | ? 0 255 | : ((number === 2) 256 | ? 1 257 | : (((number === 8) || (number === 11)) 258 | ? 2 259 | : 3)), 260 | [AvailableLocales.ro]: (number === 1) 261 | ? 1 262 | : (((number === 1) || ((number % 100 > 0) && (number % 100 < 20))) 263 | ? 2 264 | : 3), 265 | [AvailableLocales.ar]: (number === 0) 266 | ? 0 267 | : ((number === 1) 268 | ? 1 269 | : ((number === 2) 270 | ? 2 271 | : (((number % 100 >= 3) && (number % 100 <= 10)) 272 | ? 3 273 | : (((number % 100 >= 11) && (number % 100 <= 99)) 274 | ? 4 275 | : 5)))), 276 | 277 | }; 278 | return supportedForms[locale]; 279 | }; 280 | 281 | const pluralFormsCount: Record = { 282 | [AvailableLocales.az]: 2, 283 | [AvailableLocales.bo]: 2, 284 | [AvailableLocales.dz]: 2, 285 | [AvailableLocales.id]: 2, 286 | [AvailableLocales.ja]: 2, 287 | [AvailableLocales.jv]: 2, 288 | [AvailableLocales.ka]: 2, 289 | [AvailableLocales.km]: 2, 290 | [AvailableLocales.kn]: 2, 291 | [AvailableLocales.ko]: 2, 292 | [AvailableLocales.ms]: 2, 293 | [AvailableLocales.th]: 2, 294 | [AvailableLocales.tr]: 2, 295 | [AvailableLocales.vi]: 2, 296 | [AvailableLocales.zh]: 2, 297 | [AvailableLocales.zh_cn]: 2, 298 | [AvailableLocales.zh_tw]: 2, 299 | [AvailableLocales.af]: 3, 300 | [AvailableLocales.bn]: 3, 301 | [AvailableLocales.bg]: 3, 302 | [AvailableLocales.ca]: 3, 303 | [AvailableLocales.da]: 3, 304 | [AvailableLocales.de]: 3, 305 | [AvailableLocales.el]: 3, 306 | [AvailableLocales.en]: 3, 307 | [AvailableLocales.eo]: 3, 308 | [AvailableLocales.es]: 3, 309 | [AvailableLocales.et]: 3, 310 | [AvailableLocales.eu]: 3, 311 | [AvailableLocales.fa]: 3, 312 | [AvailableLocales.fi]: 3, 313 | [AvailableLocales.fo]: 3, 314 | [AvailableLocales.fur]: 3, 315 | [AvailableLocales.fy]: 3, 316 | [AvailableLocales.gl]: 3, 317 | [AvailableLocales.gu]: 3, 318 | [AvailableLocales.ha]: 3, 319 | [AvailableLocales.he]: 3, 320 | [AvailableLocales.hu]: 3, 321 | [AvailableLocales.is]: 3, 322 | [AvailableLocales.it]: 3, 323 | [AvailableLocales.ku]: 3, 324 | [AvailableLocales.lb]: 3, 325 | [AvailableLocales.ml]: 3, 326 | [AvailableLocales.mn]: 3, 327 | [AvailableLocales.mr]: 3, 328 | [AvailableLocales.nah]: 3, 329 | [AvailableLocales.nb]: 3, 330 | [AvailableLocales.ne]: 3, 331 | [AvailableLocales.nl]: 3, 332 | [AvailableLocales.nn]: 3, 333 | [AvailableLocales.no]: 3, 334 | [AvailableLocales.oc]: 3, 335 | [AvailableLocales.om]: 3, 336 | [AvailableLocales.or]: 3, 337 | [AvailableLocales.pa]: 3, 338 | [AvailableLocales.pap]: 3, 339 | [AvailableLocales.ps]: 3, 340 | [AvailableLocales.pt]: 3, 341 | [AvailableLocales.pt_pt]: 3, 342 | [AvailableLocales.pt_br]: 3, 343 | [AvailableLocales.so]: 3, 344 | [AvailableLocales.sq]: 3, 345 | [AvailableLocales.sv]: 3, 346 | [AvailableLocales.sw]: 3, 347 | [AvailableLocales.ta]: 3, 348 | [AvailableLocales.te]: 3, 349 | [AvailableLocales.tk]: 3, 350 | [AvailableLocales.ur]: 3, 351 | [AvailableLocales.zu]: 3, 352 | [AvailableLocales.am]: 2, 353 | [AvailableLocales.bh]: 2, 354 | [AvailableLocales.fil]: 2, 355 | [AvailableLocales.fr]: 3, 356 | [AvailableLocales.gun]: 2, 357 | [AvailableLocales.hi]: 2, 358 | [AvailableLocales.hy]: 2, 359 | [AvailableLocales.ln]: 2, 360 | [AvailableLocales.mg]: 2, 361 | [AvailableLocales.nso]: 2, 362 | [AvailableLocales.xbr]: 2, 363 | [AvailableLocales.ti]: 2, 364 | [AvailableLocales.wa]: 2, 365 | [AvailableLocales.be]: 4, 366 | [AvailableLocales.bs]: 4, 367 | [AvailableLocales.hr]: 4, 368 | [AvailableLocales.ru]: 4, 369 | [AvailableLocales.sr]: 4, 370 | [AvailableLocales.sr_latn]: 4, 371 | [AvailableLocales.uk]: 4, 372 | [AvailableLocales.cs]: 4, 373 | [AvailableLocales.sk]: 4, 374 | [AvailableLocales.ga]: 4, 375 | [AvailableLocales.lt]: 4, 376 | [AvailableLocales.sl]: 5, 377 | [AvailableLocales.mk]: 3, 378 | [AvailableLocales.mt]: 5, 379 | [AvailableLocales.lv]: 3, 380 | [AvailableLocales.pl]: 4, 381 | [AvailableLocales.cy]: 4, 382 | [AvailableLocales.ro]: 4, 383 | [AvailableLocales.ar]: 6, 384 | }; 385 | 386 | const PLURAL_STRING_DELIMITER = '|'; 387 | 388 | /** 389 | * Returns string plural forms which are separated by `|`. 390 | * 391 | * @param str Message. 392 | * 393 | * @returns Array of plural forms. 394 | */ 395 | export const getForms = (str: string): string[] => { 396 | return str.split(PLURAL_STRING_DELIMITER); 397 | }; 398 | 399 | /** 400 | * Checks whether the string has correct number of plural forms. 401 | * 402 | * @param str Translated string. 403 | * @param locale Locale. 404 | * @param key Optional, base key. 405 | * 406 | * @throws Error if the number of plural forms is incorrect. 407 | */ 408 | const checkForms = (str: string, locale: Locale, key?: string): void => { 409 | const givenCount = getForms(str).length; 410 | const requiredCount = pluralFormsCount[locale]; 411 | 412 | // e.g. 'sr-latn' may be passed and it is not supported, 'sr_latn' should be used 413 | if (typeof requiredCount === 'undefined') { 414 | throw new Error(`Locale is not supported: '${locale}'`); 415 | } 416 | 417 | if (givenCount !== requiredCount) { 418 | const prefix = typeof key !== 'undefined' 419 | ? `Invalid plural string "${key}" for locale '${locale}'` 420 | : `Invalid plural string for locale '${locale}'`; 421 | throw new Error(`${prefix}: required ${requiredCount}, given ${givenCount} in string "${str}"`); 422 | } 423 | }; 424 | 425 | /** 426 | * Checks whether plural forms are present in base string 427 | * by checking the presence of the vertical bar `|`. 428 | * 429 | * @param baseStr Base string. 430 | * 431 | * @returns True if `baseStr` contains `|`, false otherwise. 432 | */ 433 | export const hasPluralForm = (baseStr: string): boolean => { 434 | return baseStr.includes(PLURAL_STRING_DELIMITER); 435 | }; 436 | 437 | /** 438 | * Checks if plural forms are valid. 439 | * 440 | * @param targetStr Translated message with plural forms. 441 | * @param locale Locale. 442 | * @param key Optional, message key, used for clearer log message. 443 | * 444 | * @returns True if plural forms are valid, false otherwise. 445 | */ 446 | export const isPluralFormValid = (targetStr: string, locale: Locale, key?: string): boolean => { 447 | try { 448 | checkForms(targetStr, locale, key); 449 | return true; 450 | } catch (error) { 451 | return false; 452 | } 453 | }; 454 | 455 | /** 456 | * Returns plural form corresponding to number 457 | * @param str 458 | * @param number 459 | * @param locale - current locale 460 | * @param key - message key 461 | */ 462 | export const getForm = (str: string, number: number, locale: Locale, key: string): string => { 463 | checkForms(str, locale, key); 464 | const forms = getForms(str); 465 | const currentForm = getPluralFormId(locale, number); 466 | return forms[currentForm].trim(); 467 | }; 468 | --------------------------------------------------------------------------------