├── .gitignore ├── src ├── react │ ├── index.ts │ ├── context.ts │ ├── components.tsx │ └── hooks.ts ├── index.ts ├── plurals.ts └── i18n.ts ├── CHANGELOG.md ├── tsconfig.json ├── package.json ├── LICENSE.MD └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /src/react/index.ts: -------------------------------------------------------------------------------- 1 | export { I18NProvider, TaggedText } from "./components"; 2 | export { useI18N, useTranslate } from "./hooks"; 3 | -------------------------------------------------------------------------------- /src/react/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { I18N } from "../i18n"; 3 | 4 | export const I18NContext = createContext | null>(null); 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { I18N } from "./i18n"; 2 | export { I18NProvider, useI18N, useTranslate, TaggedText } from "./react"; 3 | export { pluralizeEn, pluralizeRu, createPluralize } from "./plurals"; 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Version 0.2.1 2 | 3 | - removed `inferParameterTypes` because of the issues with TypeScript 4 | 5 | ## Version 0.2.0 6 | 7 | - Added new flag in I18N options called `inferParameterTypes` which could automatically infer mustache parameters. 8 | 9 | ## Version 0.1.1 10 | 11 | - Added a `createPluralize` function instead of the separate `pluralizeEn` / `pluralizeRu` functions. 12 | - Deprecated `pluralizeEn` and `pluralizeRu` functions. 13 | - Improved documentation. 14 | 15 | ## Version 0.0.5 16 | 17 | - `useI18n` hook now returns an object instead of the `I18N` instance. The returned values are now bound and will update it's reference whenever the language changes. Also `getLang` was deprecated in favor of the `lang`. 18 | 19 | ## Version 0.0.4 20 | 21 | - Initial release. 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "ESNext"], 5 | "jsx": "react-jsx", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "outDir": "./dist", 10 | "declaration": true, 11 | "allowJs": false, 12 | "isolatedModules": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "exactOptionalPropertyTypes": false, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | "noPropertyAccessFromIndexSignature": false, 24 | "allowUnusedLabels": false, 25 | "allowUnreachableCode": false, 26 | "skipLibCheck": true 27 | }, 28 | "include": ["./src"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ayub-begimkulov/i18n", 3 | "version": "0.2.1", 4 | "description": "Small and type-safe package to create multi-language interfaces.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "sideEffects": false, 8 | "scripts": { 9 | "build": "rm -rf dist && tsc" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/Ayub-Begimkulov/i18n.git" 14 | }, 15 | "keywords": [ 16 | "galera", 17 | "i18n", 18 | "react-i18n" 19 | ], 20 | "author": "Ayub Begimkulov", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/Ayub-Begimkulov/i18n/issues" 24 | }, 25 | "homepage": "https://github.com/Ayub-Begimkulov/i18n#readme", 26 | "devDependencies": { 27 | "@types/react": "^18.0.28", 28 | "react": "^18.2.0", 29 | "typescript": "^4.9.5" 30 | }, 31 | "peerDependencies": { 32 | "react": ">=16.8.0 || 17.x || 18.x" 33 | }, 34 | "peerDependenciesMeta": { 35 | "react": { 36 | "optional": true 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.MD: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ayub Begimkulov 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 | -------------------------------------------------------------------------------- /src/plurals.ts: -------------------------------------------------------------------------------- 1 | export function createPluralize(locale: string) { 2 | const rules = new Intl.PluralRules(locale); 3 | 4 | const pluralize = (count: number) => { 5 | return rules.select(count); 6 | }; 7 | 8 | return pluralize; 9 | } 10 | 11 | /** 12 | * @deprecated Use `createPluralize('en')` instead. 13 | */ 14 | export function pluralizeEn(number: number) { 15 | const i = Math.floor(Math.abs(number)); 16 | 17 | if (i === 1) { 18 | return "one"; 19 | } 20 | 21 | return "other"; 22 | } 23 | 24 | /** 25 | * @deprecated Use `createPluralize('ru')` instead. 26 | */ 27 | export function pluralizeRu(number: number) { 28 | const absNumber = Math.floor(Math.abs(number)); 29 | 30 | if (absNumber % 10 === 1 && !(absNumber % 100 === 11)) return "one"; 31 | 32 | if ( 33 | absNumber % 10 === Math.floor(absNumber % 10) && 34 | absNumber % 10 >= 2 && 35 | absNumber % 10 <= 4 && 36 | !(absNumber % 100 >= 12 && absNumber % 100 <= 14) 37 | ) { 38 | return "few"; 39 | } 40 | 41 | if ( 42 | absNumber % 10 === 0 || 43 | (absNumber % 10 === Math.floor(absNumber % 10) && 44 | absNumber % 10 >= 5 && 45 | absNumber % 10 <= 9) || 46 | (absNumber % 100 === Math.floor(absNumber % 100) && 47 | absNumber % 100 >= 11 && 48 | absNumber % 100 <= 14) 49 | ) { 50 | return "many"; 51 | } 52 | 53 | return "other"; 54 | } 55 | -------------------------------------------------------------------------------- /src/react/components.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | 3 | import { I18N } from "../i18n"; 4 | import { I18NContext } from "./context"; 5 | 6 | interface I18NProviderProps { 7 | i18n: I18N; 8 | children: React.ReactNode; 9 | } 10 | 11 | export const I18NProvider = ({ i18n, children }: I18NProviderProps) => { 12 | return {children}; 13 | }; 14 | 15 | interface TaggedTextProps { 16 | text: string; 17 | tags?: Record JSX.Element>; 18 | } 19 | 20 | const tagsRegex = /(<\d+>[^<>]*<\/\d+>)/; 21 | const openCloseTagRegex = /<(\d+)>([^<>]*)<\/(\d+)>/; 22 | 23 | const interpolateTags = ( 24 | text: string, 25 | params?: Record JSX.Element> 26 | ) => { 27 | if (!params) { 28 | return text; 29 | } 30 | 31 | const tokens = text.split(tagsRegex); 32 | 33 | return tokens.map((token) => { 34 | const matchResult = openCloseTagRegex.exec(token); 35 | 36 | if (!matchResult) { 37 | return token; 38 | } 39 | 40 | const [, openTag, content, closeTag] = matchResult; 41 | 42 | if (!openTag || !closeTag || openTag !== closeTag) { 43 | return token; 44 | } 45 | 46 | return ( 47 | {params[openTag]?.(content ?? "")} 48 | ); 49 | }); 50 | }; 51 | 52 | export const TaggedText = ({ text, tags }: TaggedTextProps) => { 53 | return <>{interpolateTags(text, tags)}; 54 | }; 55 | -------------------------------------------------------------------------------- /src/react/hooks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useCallback, 3 | useContext, 4 | useEffect, 5 | useReducer, 6 | useRef, 7 | useState, 8 | } from "react"; 9 | import type { I18N } from "../i18n"; 10 | import { I18NContext } from "./context"; 11 | 12 | function useI18NContext() { 13 | const i18n = useContext(I18NContext); 14 | 15 | if (!i18n) { 16 | throw new Error("can not `useI18NContext` outside of the `I18NProvider`"); 17 | } 18 | 19 | return i18n; 20 | } 21 | 22 | interface ReactI18N> { 23 | readonly lang: ReturnType; 24 | get: I18NType["get"]; 25 | /** 26 | * @deprecated use `lang` instead 27 | */ 28 | getLang: I18NType["getLang"]; 29 | setLang: I18NType["setLang"]; 30 | subscribe: I18NType["subscribe"]; 31 | } 32 | 33 | export function useI18N>() { 34 | const i18n = useI18NContext() as I18NType; 35 | const [{ langState, updateCount }, setLangState] = useState(() => ({ 36 | langState: i18n.getLang(), 37 | updateCount: 0, 38 | })); 39 | const usesLang = useRef(false); 40 | 41 | useEffect(() => { 42 | i18n.subscribe((lang) => { 43 | // only update the state if 44 | // the lang is used 45 | if (!usesLang.current) { 46 | return; 47 | } 48 | 49 | setLangState((state) => ({ 50 | langState: lang, 51 | updateCount: state.updateCount + 1, 52 | })); 53 | }); 54 | }, [i18n]); 55 | 56 | const get: typeof i18n.get = useCallback( 57 | (key, ...rest) => { 58 | usesLang.current = true; 59 | return i18n.get(key, ...rest); 60 | }, 61 | // include the `updateCount` into the deps array 62 | // so that `get` function changes it's reference whenever 63 | // the languages changes or the translations are loaded 64 | [i18n, updateCount] 65 | ); 66 | 67 | const getLang: typeof i18n.getLang = useCallback( 68 | () => { 69 | usesLang.current = true; 70 | return i18n.getLang(); 71 | }, 72 | // include the `langState` into the deps array 73 | // so that `get` function changes it's reference whenever 74 | // the languages changes 75 | [i18n, langState] 76 | ); 77 | 78 | const setLang: typeof i18n.setLang = useCallback( 79 | (newLang) => i18n.setLang(newLang), 80 | [i18n] 81 | ); 82 | 83 | const subscribe: typeof i18n.subscribe = useCallback( 84 | (cb, options) => i18n.subscribe(cb, options), 85 | [i18n] 86 | ); 87 | 88 | const reactI18N = { 89 | get lang() { 90 | usesLang.current = true; 91 | return langState; 92 | }, 93 | get, 94 | getLang, 95 | setLang, 96 | subscribe, 97 | } as ReactI18N; 98 | 99 | return reactI18N; 100 | } 101 | 102 | export function useTranslate>() { 103 | const i18n = useI18NContext(); 104 | const [updateCount, triggerUpdate] = useReducer((v) => v + 1, 0); 105 | 106 | useEffect(() => { 107 | return i18n.subscribe(() => { 108 | triggerUpdate(); 109 | }); 110 | }, []); 111 | 112 | const translate: I18NType["get"] = useCallback( 113 | (key, ...rest) => { 114 | return i18n.get(key, ...rest); 115 | }, 116 | // include the `updateCount` into the deps array 117 | // so that translate changes it's reference whenever the language changes 118 | [updateCount] 119 | ); 120 | 121 | return translate; 122 | } 123 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | // we might have translation as an object, if it's plural 2 | type Translation = string | Record; 3 | type Keyset = Record; 4 | 5 | export type LanguageConfig = { 6 | keyset: Keyset | (() => Promise); 7 | pluralize: (count: number) => string; 8 | }; 9 | 10 | interface I18NOptions< 11 | LanguagesMap extends Record, 12 | Lang extends keyof LanguagesMap = keyof LanguagesMap 13 | > { 14 | defaultLang: Lang; 15 | languages: LanguagesMap; 16 | } 17 | 18 | type KeyType> = 19 | keyof KeysetType; 20 | 21 | type KeysetType> = 22 | UnwrapKeysetType; 23 | 24 | type UnwrapKeysetType< 25 | MaybeUnresolvedKeyset extends Keyset | (() => Promise) 26 | > = MaybeUnresolvedKeyset extends () => Promise 27 | ? ResolvedKeyset 28 | : MaybeUnresolvedKeyset; 29 | 30 | type GetRestParams< 31 | KeysetsMap extends Record, 32 | Key extends KeyType 33 | > = KeysetType[Key] extends object 34 | ? [options: { count: number; [key: string]: number | string }] 35 | : [options?: Record]; 36 | 37 | export class I18N> { 38 | private lang: keyof KeysetsMap; 39 | private subscribers = new Set<(lang: keyof KeysetsMap) => void>(); 40 | private keysets: KeysetsMap; 41 | 42 | constructor(options: I18NOptions) { 43 | this.keysets = options.languages; 44 | 45 | this.setLang(options.defaultLang); 46 | // call it just for the TS to not complain 47 | this.lang = options.defaultLang; 48 | } 49 | 50 | getLang() { 51 | return this.lang; 52 | } 53 | 54 | get>( 55 | key: Key, 56 | ...rest: GetRestParams 57 | ): string { 58 | const { keyset, pluralize } = this.keysets[this.lang]!; 59 | 60 | if (typeof keyset === "function") { 61 | return String(key); 62 | } 63 | 64 | const translation: string | Record | undefined = 65 | keyset[key]; 66 | 67 | if (typeof translation === "undefined") { 68 | return String(key); 69 | } 70 | const params: Record = rest[0] || {}; 71 | if (typeof translation === "string") { 72 | return interpolateTranslation(translation, params); 73 | } 74 | 75 | const pluralKey = pluralize(params.count as number); 76 | 77 | const pluralizedTranslation = translation[pluralKey]!; 78 | 79 | return interpolateTranslation(pluralizedTranslation, params); 80 | } 81 | 82 | async setLang(newLang: keyof KeysetsMap) { 83 | try { 84 | if (newLang === this.lang) { 85 | return; 86 | } 87 | 88 | const { keyset } = this.keysets[newLang]!; 89 | 90 | if (typeof keyset === "function") { 91 | const resolvedKeyset = await keyset(); 92 | 93 | this.keysets[newLang]!.keyset = resolvedKeyset; 94 | } 95 | 96 | this.lang = newLang; 97 | 98 | this.subscribers.forEach((cb) => cb(newLang)); 99 | } catch (error) { 100 | console.error( 101 | `Error happened trying to update language. Can not resolve lazy loaded keyset for "${String( 102 | newLang 103 | )}" language. See the error below to get more details` 104 | ); 105 | throw error; 106 | } 107 | } 108 | 109 | subscribe( 110 | cb: (fn: keyof KeysetsMap) => void, 111 | options?: { immediate: boolean } 112 | ) { 113 | this.subscribers.add(cb); 114 | 115 | if (options?.immediate) { 116 | cb(this.lang); 117 | } 118 | 119 | return () => { 120 | this.subscribers.delete(cb); 121 | }; 122 | } 123 | } 124 | 125 | const mustacheParamRegex = /\{\{\s*([a-zA-Z10-9]+)\s*\}\}/g; 126 | 127 | // not the most performant way, but it should be okay 128 | function interpolateTranslation( 129 | translation: string, 130 | params: Record 131 | ) { 132 | return translation.replace(mustacheParamRegex, (original, paramKey) => { 133 | if (paramKey in params) { 134 | return String(params[paramKey]); 135 | } 136 | 137 | return original; 138 | }); 139 | } 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @ayub-begimkulov/i18n 2 | 3 | Small and type-safe package to create multi-language interfaces. 4 | 5 | Features 6 | 7 | - Base i18n functionality (interpolation, rich text, pluralization). 8 | - Full TS support. They types are inferred, no need to write anything by hand. 9 | - React hooks and components. 10 | - Ability to load translations asynchronously (from the server or by lazy loading). 11 | - Ability to work with plurals format that is comfortable for you. 12 | 13 | ## Installation 14 | 15 | ```shell 16 | npm i @ayub-begimkulov/i18n 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### Basic example 22 | 23 | ```ts 24 | import { I18N, createPluralize } from "@ayub-begimkulov/i18n"; 25 | import en from "./keys/en.json"; 26 | import ru from "./keys/ru.json"; 27 | 28 | const pluralizeEn = createPluralize("en"); 29 | const pluralizeRu = createPluralize("ru"); 30 | 31 | const i18n = new I18N({ 32 | defaultLang: "en", 33 | languages: { 34 | en: { 35 | keyset: en, 36 | pluralize: pluralizeEn, 37 | }, 38 | ru: { 39 | keyset: ru, 40 | pluralize: pluralizeRu, 41 | }, 42 | }, 43 | }); 44 | 45 | i18n.get("greeting"); // Hello 46 | ``` 47 | 48 | ### Using with React 49 | 50 | ```ts 51 | // i18n.ts 52 | import { 53 | I18N, 54 | createPluralize, 55 | useTranslate as useTranslateBase, 56 | useI18n as useI18nBase, 57 | } from "@ayub-begimkulov/i18n"; 58 | import en from "./keys/en.json"; 59 | import ru from "./keys/ru.json"; 60 | 61 | const pluralizeEn = createPluralize("en"); 62 | const pluralizeRu = createPluralize("ru"); 63 | 64 | const i18n = new I18N({ 65 | defaultLang: "en", 66 | languages: { 67 | en: { 68 | keyset: en, 69 | pluralize: pluralizeEn, 70 | }, 71 | ru: { 72 | keyset: ru, 73 | pluralize: pluralizeRu, 74 | }, 75 | }, 76 | }); 77 | 78 | export const useTranslate = useTranslateBase; 79 | export const useI18n = useI18nBase; 80 | 81 | // index.tsx 82 | import { I18NProvider } from "@ayub-begimkulov/i18n"; 83 | import { i18n } from "./i18n"; 84 | 85 | // ... 86 | 87 | root.render( 88 | 89 | 90 | 91 | ); 92 | 93 | // component.ts 94 | import { useTranslate } from "./i18n"; 95 | 96 | const Component = () => { 97 | const translate = useTranslate(); 98 | 99 | return
{translate("some_key")}
; 100 | }; 101 | ``` 102 | 103 | 110 | 111 | ## Recipes 112 | 113 | TODO 114 | 115 | ## Reference 116 | 117 | ### `I18N` 118 | 119 | Class that is responsible for loading/storing translations and updating language. All the react functionality leverages `I18N` API to update components whenever needed. 120 | 121 | Example: 122 | 123 | ```ts 124 | import { I18N, createPluralize } from "@ayub-begimkulov/i18n"; 125 | import en from "./keys/en.json"; 126 | import ru from "./keys/ru.json"; 127 | 128 | const pluralizeEn = createPluralize("en"); 129 | const pluralizeRu = createPluralize("ru"); 130 | 131 | const i18n = new I18N({ 132 | defaultLang: "en", 133 | languages: { 134 | en: { 135 | keyset: en, 136 | pluralize: pluralizeEn, 137 | }, 138 | ru: { 139 | keyset: ru, 140 | pluralize: pluralizeRu, 141 | }, 142 | }, 143 | }); 144 | 145 | i18n.get("greeting"); // 'Hello' 146 | ``` 147 | 148 | ### `useI18n` 149 | 150 | A hook that returns an object with properties/methods of the i18n. Updates a component whenever the language changes. 151 | 152 | Example: 153 | 154 | ```tsx 155 | const allLanguages = ["en", "ru", "ar"]; 156 | 157 | const Component = () => { 158 | const { lang, setLang } = useI18n(); 159 | 160 | return ( 161 | <> 162 |
{lang}
163 | {allLanguages.map((lang) => ( 164 |
setLang(lang)}>Select {lang}
165 | ))} 166 | 167 | ); 168 | }; 169 | ``` 170 | 171 | ### `useTranslate` 172 | 173 | A hook that returns a translate function. The component that uses this hook will update whenever the language changes. 174 | 175 | Example: 176 | 177 | ```tsx 178 | import { useTranslate } from "@ayub-begimkulov/i18n"; 179 | 180 | const Component = () => { 181 | const t = useTranslate(); 182 | 183 | return
{t("welcome")}
; 184 | }; 185 | ``` 186 | 187 | ### `createPluralize` 188 | 189 | Creates a pluralize function for a given locale that will return a plural format for a specific number of items. Leverages [`Intl.PluralRules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) under the hood. 190 | 191 | Example: 192 | 193 | ```ts 194 | import { createPluralize } from "@ayub-begimkulov/i18n"; 195 | 196 | const pluralizeEn = createPluralize("en"); 197 | 198 | pluralizeEn(1); // 'one' 199 | pluralizeEn(0); // 'other' 200 | ``` 201 | 202 | ### `I18NProvider` 203 | 204 | A wrapper around React context provider. Used to share `I18N` instance across the components. Mostly used inside of the library hooks (`useI18n`/`useTranslate`). 205 | 206 | Example: 207 | 208 | ```tsx 209 | import { I18NProvider } from '@ayub-begimkulov/i18n'; 210 | import { App } from './App'; 211 | import { i18n } form './i18n'; 212 | 213 | root.render( 214 | 215 | 216 | 217 | ) 218 | ``` 219 | 220 | ### `TaggedText` 221 | 222 | A component that allows to use React component inside of your translations. 223 | 224 | Example: 225 | 226 | ```tsx 227 | import { TaggedText, useTranslate } from "@ayub-begimkulov/i18n"; 228 | 229 | export const Component = () => { 230 | const t = useTranslate(); 231 | 232 | console.log(t("key")); // "<1>Important! Check out <2>the project documentation before using this library" 233 | 234 | return ( 235 |
236 | {text}, 240 | 2: (text) => {text}, 241 | }} 242 | /> 243 |
244 | ); 245 | }; 246 | ``` 247 | 248 | ## License 249 | 250 | MIT. 251 | --------------------------------------------------------------------------------