Skip to content

Commit

Permalink
chore: deprecate i18n-js package with custom i18n class translati…
Browse files Browse the repository at this point in the history
…on (#1736)

* chore: deprecate i18n-js library and introduce custom made translation class

* chore: tweak comments

* chore: changeset

* chore: tweak test name

* chore: remove console.log

* chore: tweak test names

* chore: bump I18n into locale dir

* chore: tweak changeset

---------

Co-authored-by: Daniel Sinclair <d@niel.nyc>
  • Loading branch information
magiziz and DanielSinclair committed Feb 1, 2024
1 parent c0a644a commit e5f5f03
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/plenty-terms-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rainbow-me/rainbowkit": patch
---

Removed external `i18n-js` dependency to reduce RainbowKit bundle sizes.
4 changes: 1 addition & 3 deletions packages/rainbowkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -73,12 +72,11 @@
"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": {
"type": "git",
"url": "git+https://github.com/rainbow-me/rainbowkit.git",
"directory": "packages/rainbowkit"
}
}
}
2 changes: 0 additions & 2 deletions packages/rainbowkit/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,3 @@ declare module '*.png' {
const dataUrl: string;
export default dataUrl;
}

declare module 'i18n-js/dist/require/index.js';
138 changes: 138 additions & 0 deletions packages/rainbowkit/src/locales/I18n.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
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);
});
});
});
143 changes: 143 additions & 0 deletions packages/rainbowkit/src/locales/I18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
type GenericTranslationObject = Record<string, any>;

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<string, GenericTranslationObject>) {
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<string, string> = {},
) {
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, string>): 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);
};
}
}
22 changes: 10 additions & 12 deletions packages/rainbowkit/src/locales/index.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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<any> => {
const fetchTranslations = async (locale: Locale): Promise<any> => {
switch (locale) {
case 'ar':
case 'ar-AR':
Expand Down Expand Up @@ -92,12 +91,11 @@ const fetchLocale = async (locale: Locale): Promise<any> => {

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));
}
Loading

0 comments on commit e5f5f03

Please sign in to comment.