diff --git a/.changeset/plenty-terms-share.md b/.changeset/plenty-terms-share.md new file mode 100644 index 0000000000..ce32129abb --- /dev/null +++ b/.changeset/plenty-terms-share.md @@ -0,0 +1,5 @@ +--- +"@rainbow-me/rainbowkit": patch +--- + +Removed external `i18n-js` dependency to reduce RainbowKit bundle sizes. diff --git a/packages/rainbowkit/package.json b/packages/rainbowkit/package.json index 802ee5b3cc..e015d6449f 100644 --- a/packages/rainbowkit/package.json +++ b/packages/rainbowkit/package.json @@ -63,7 +63,6 @@ "vitest": "^0.33.0", "wagmi": "~1.4.13", "@wagmi/core": "~1.4.13", - "@types/i18n-js": "^3.8.9", "@types/ua-parser-js": "^0.7.39" }, "dependencies": { @@ -73,7 +72,6 @@ "clsx": "2.1.0", "qrcode": "1.5.3", "react-remove-scroll": "2.5.7", - "i18n-js": "^4.3.2", "ua-parser-js": "^1.0.37" }, "repository": { @@ -81,4 +79,4 @@ "url": "git+https://github.com/rainbow-me/rainbowkit.git", "directory": "packages/rainbowkit" } -} \ No newline at end of file +} diff --git a/packages/rainbowkit/src/global.d.ts b/packages/rainbowkit/src/global.d.ts index 01f696eb10..85a65c48e5 100644 --- a/packages/rainbowkit/src/global.d.ts +++ b/packages/rainbowkit/src/global.d.ts @@ -7,5 +7,3 @@ declare module '*.png' { const dataUrl: string; export default dataUrl; } - -declare module 'i18n-js/dist/require/index.js'; diff --git a/packages/rainbowkit/src/locales/I18n.test.ts b/packages/rainbowkit/src/locales/I18n.test.ts new file mode 100644 index 0000000000..a8f7e2b559 --- /dev/null +++ b/packages/rainbowkit/src/locales/I18n.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { I18n } from './I18n'; + +describe('I18n', () => { + describe('t (translate)', () => { + const testBasicTranslation = ( + locale: string, + translations: Record, + key: string, + ) => { + const i18n = new I18n({ [locale]: translations }); + i18n.locale = locale; + + it(`should translate '${locale}' locale for '${key}'`, () => { + expect(i18n.t(key)).toBe(translations[key]); + }); + }; + + // Basic translation for 'hello' key + testBasicTranslation('en-US', { hello: 'hello' }, 'hello'); + testBasicTranslation('ru-RU', { hello: 'привет' }, 'hello'); + testBasicTranslation('ja-JP', { hello: 'こんにちは' }, 'hello'); + testBasicTranslation('ar-AR', { hello: 'مرحبًا' }, 'hello'); + + it("should translate 'en-US' if 'ja-JP' translation is missing (fallback enabled)", () => { + const i18n = new I18n({ + 'en-US': { + hello: 'hello', + }, + 'ja-JP': { + apple: 'りんご', + }, + }); + + i18n.enableFallback = true; + // defaultLocale will be used + // as fallback translation + i18n.defaultLocale = 'en-US'; + i18n.locale = 'ja-JP'; + + expect(i18n.t('hello')).toBe('hello'); + }); + + it('should return missing message if translation does not exist', () => { + const i18n = new I18n({ + 'ja-JP': { + hello: 'こんにちは', + }, + }); + + i18n.locale = 'ja-JP'; + + expect(i18n.t('xyz')).toBe(`[missing: "ja-JP.xyz" translation]`); + }); + + it('should return missing message if no locale present', () => { + const i18n = new I18n({}); + + i18n.locale = 'ja-JP'; + + expect(i18n.t('xyz')).toBe(`[missing: "ja-JP.xyz" translation]`); + }); + + it("should return missing message if 'ja-JP' has missing translation (fallback disabled)", () => { + const i18n = new I18n({ + 'en-US': { + hello: 'hello', + }, + 'ja-JP': { + apple: 'りんご', + }, + }); + + i18n.defaultLocale = 'en-US'; + i18n.locale = 'ja-JP'; + + expect(i18n.t('hello')).toBe(`[missing: "ja-JP.hello" translation]`); + }); + + it('should translate with replacement', () => { + const i18n = new I18n({ + 'en-US': { + hello: 'hello %{firstName} %{lastName}', + }, + }); + + i18n.locale = 'en-US'; + + expect(i18n.t('hello', { firstName: 'john', lastName: 'doe' })).toBe( + 'hello john doe', + ); + }); + }); + + describe('onChange', () => { + it('should call onChange function if locale is updated', () => { + const i18n = new I18n({ + 'en-US': { + hello: 'hello', + }, + }); + + let called = false; + + i18n.onChange(() => { + called = true; + }); + + i18n.setTranslations('ru-RU', { + hello: 'привет', + }); + + expect(called).toBe(true); + }); + + it('should unsubscribe onChange if cleanup function is called', () => { + const i18n = new I18n({ + 'en-US': { + hello: 'hello', + }, + }); + + let called = false; + + const unsubscribe = i18n.onChange(() => { + called = true; + }); + + unsubscribe(); // Unsubscribe immediately + + i18n.setTranslations('ru-RU', { + hello: 'привет', + }); + + expect(called).toBe(false); + }); + }); +}); diff --git a/packages/rainbowkit/src/locales/I18n.ts b/packages/rainbowkit/src/locales/I18n.ts new file mode 100644 index 0000000000..5bb4c366ee --- /dev/null +++ b/packages/rainbowkit/src/locales/I18n.ts @@ -0,0 +1,143 @@ +type GenericTranslationObject = Record; + +const defaultOptions = { + defaultLocale: 'en', + locale: 'en', +}; + +export class I18n { + public listeners: Set<() => void> = new Set(); + public defaultLocale = defaultOptions.defaultLocale; + public enableFallback = false; + public locale = defaultOptions.locale; + private cachedLocales: string[] = []; + public translations: GenericTranslationObject = {}; + + constructor(localeTranslations: Record) { + for (const [locale, translation] of Object.entries(localeTranslations)) { + this.cachedLocales = [...this.cachedLocales, locale]; + this.translations = { + ...this.translations, + ...this.flattenTranslation(translation, locale), + }; + } + } + + private missingMessage(key: string): string { + return `[missing: "${this.locale}.${key}" translation]`; + } + + private flattenTranslation( + translationObject: GenericTranslationObject, + locale: string, + ): GenericTranslationObject { + const result: GenericTranslationObject = {}; + + const flatten = ( + currentTranslationObj: GenericTranslationObject, + parentKey: string, + ) => { + for (const key of Object.keys(currentTranslationObj)) { + // Generate a new key for each iteration e.g 'en-US.connect.title' + const newKey = `${parentKey}.${key}`; + const currentValue = currentTranslationObj[key]; + + // If more nested values are encountered in the object, then + // the same function will be called again + if (typeof currentValue === 'object' && currentValue !== null) { + flatten(currentValue, newKey); + } else { + // Otherwise, assign the result to the final + // object value with the new key + result[newKey] = currentValue; + } + } + }; + + flatten(translationObject, locale); + return result; + } + + private translateWithReplacements( + translation: string, + replacements: Record = {}, + ) { + let translatedString = translation; + for (const placeholder in replacements) { + const replacementValue = replacements[placeholder]; + translatedString = translatedString.replace( + `%{${placeholder}}`, + replacementValue, + ); + } + return translatedString; + } + + public t(key: string, replacements?: Record): string { + const translationKey = `${this.locale}.${key}`; + const translation = this.translations[translationKey]; + + if (!translation) { + // If fallback is enabled + if (this.enableFallback) { + const fallbackTranslationKey = `${this.defaultLocale}.${key}`; + const fallbackTranslation = this.translations[fallbackTranslationKey]; + + // If translation exist for the default + // locale return it as a fallback translation + if (fallbackTranslation) { + return this.translateWithReplacements( + fallbackTranslation, + replacements, + ); + } + } + + return this.missingMessage(key); + } + + return this.translateWithReplacements(translation, replacements); + } + + public isLocaleCached(locale: string) { + return this.cachedLocales.includes(locale); + } + + public updateLocale(locale: string) { + this.locale = locale; + this.notifyListeners(); + } + + public setTranslations( + locale: string, + translations: GenericTranslationObject, + ) { + const cachedLocale = this.isLocaleCached(locale); + + if (!cachedLocale) { + this.cachedLocales = [...this.cachedLocales, locale]; + this.translations = { + ...this.translations, + ...this.flattenTranslation(translations, locale), + }; + } + + this.locale = locale; + + this.notifyListeners(); + } + + private notifyListeners(): void { + for (const listener of this.listeners) { + listener(); + } + } + + public onChange(fn: () => void): () => void { + this.listeners.add(fn); + + return () => { + this.listeners.delete(fn); + }; + } +} diff --git a/packages/rainbowkit/src/locales/index.ts b/packages/rainbowkit/src/locales/index.ts index 7b20256e9c..6c8edb4a31 100644 --- a/packages/rainbowkit/src/locales/index.ts +++ b/packages/rainbowkit/src/locales/index.ts @@ -1,5 +1,4 @@ -import type * as I18nTypes from 'i18n-js'; -import { I18n } from 'i18n-js/dist/require/index.js'; +import { I18n } from './I18n'; import en_US from './en_US.json'; export type Locale = @@ -32,16 +31,16 @@ export type Locale = | 'zh' | 'zh-CN'; -// biome-ignore format: locale keys -export const i18n: I18nTypes.I18n = new I18n({ - 'en': JSON.parse(en_US as any), +export const i18n = new I18n({ + en: JSON.parse(en_US as any), 'en-US': JSON.parse(en_US as any), }); i18n.defaultLocale = 'en-US'; +i18n.locale = 'en-US'; i18n.enableFallback = true; -const fetchLocale = async (locale: Locale): Promise => { +const fetchTranslations = async (locale: Locale): Promise => { switch (locale) { case 'ar': case 'ar-AR': @@ -92,12 +91,11 @@ const fetchLocale = async (locale: Locale): Promise => { export async function setLocale(locale: Locale) { // If i18n translation already exists no need to refetch the local files again - if (i18n.translations[locale]) { - i18n.locale = locale; + const isCached = i18n.isLocaleCached(locale); + if (isCached) { + i18n.updateLocale(locale); return; } - - const localeFile = (await fetchLocale(locale)) as string; - i18n.translations[locale] = JSON.parse(localeFile); - i18n.locale = locale; + const translations = await fetchTranslations(locale); + i18n.setTranslations(locale, JSON.parse(translations)); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbb40c374f..ea6da27c0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -449,9 +449,6 @@ importers: clsx: specifier: 2.1.0 version: 2.1.0 - i18n-js: - specifier: ^4.3.2 - version: 4.3.2 qrcode: specifier: 1.5.3 version: 1.5.3 @@ -474,9 +471,6 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@9.3.1) - '@types/i18n-js': - specifier: ^3.8.9 - version: 3.8.9 '@types/qrcode': specifier: ^1.5.5 version: 1.5.5 @@ -9266,10 +9260,6 @@ packages: '@types/node': 18.19.4 dev: false - /@types/i18n-js@3.8.9: - resolution: {integrity: sha512-bSxgya4x5O+x+QhfCGckiDDE+17XGPp1TNBgBA/vfF5EwdiZC70F4cKG5QK2v44+v62oY7/t/InreRhxskulcA==} - dev: true - /@types/istanbul-lib-coverage@2.0.4: resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} dev: false @@ -11741,10 +11731,6 @@ packages: dependencies: bindings: 1.5.0 - /bignumber.js@9.1.2: - resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==} - dev: false - /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -16425,14 +16411,6 @@ packages: engines: {node: '>=14'} dev: true - /i18n-js@4.3.2: - resolution: {integrity: sha512-n8gbEbQEueym2/q2yrZk5/xKWjFcKtg3/Escw4JHSVWa8qtKqP8j7se3UjkRbHlO/REqFA0V/MG1q8tEfyHeOA==} - dependencies: - bignumber.js: 9.1.2 - lodash: 4.17.21 - make-plural: 7.3.0 - dev: false - /iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -18345,10 +18323,6 @@ packages: semver: 6.3.1 dev: false - /make-plural@7.3.0: - resolution: {integrity: sha512-/K3BC0KIsO+WK2i94LkMPv3wslMrazrQhfi5We9fMbLlLjzoOSJWr7TAdupLlDWaJcWxwoNosBkhFDejiu5VDw==} - dev: false - /makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} dependencies: