Repository: xiCO2k/laravel-vue-i18n
Pull request #59 (2022-08-24 08:08:59)

Refactor to class instance, allowing multiple instances
This PR is a follow-up to https://github.com/xiCO2k/laravel-vue-i18n/issues/56 and refactors the library to use a class-based approach. **There are no breaking changes** - existing usages should continue to work just fine. Tests have been provided.

The goal is to allow creating multiple translator instances, which allows translating different parts of the UI in different languages. This is useful in certain scenarios, such as for a translation tool, or some app that displays a preview of some document or component in a different language than the rest of the UI.

### Implementation details

All language loading and translation logic remains the same - it has merely been moved inside the `I18n` class. It is possible to create multiple instances of this class, each with different options (language, resolver). These different instances can be created directly in a Vue component, or installed as a Vue app plugin.

```js
import { I18n } from 'laravel-vue-i18n'

const resolver = lang => import(`./fixtures/lang/${lang}.json`)

const i18nEn = new I18n({
    lang: 'en',
    resolve: resolver
})
const i18nPt = new I18n({
    lang: 'pt',
    resolve: resolver
})

i18nEn.trans('Welcome!') // will output "Welcome!"
i18nPt.trans('Welcome!') // will output "Bem-vindo!"
```

By default, installing the the `i18nVue` plugin will create a shared instance. This instance is accessed when importing the translation functions, such as `trans`, directly. When using multiple Vue app instances, it's possible to either share the `I18n` instance between them, or have each app create its own instance.

**shared usage (default - also the current behavior)**
```js
import { i18nVue } from 'laravel-vue-i18n'

createApp()
    .use(i18nVue, { lang: 'pt' })
    .mount('#app');
    
// elsewhere
import { trans } from 'laravel-vue-i18n'

trans('Welcome!') // will output "Bem-vindo!"
```

**non-shared usage - makes sense only if creating multiple Vue app instances**
```js
import { i18nVue } from 'laravel-vue-i18n'

createApp()
    .use(i18nVue, {
        lang: 'es'
        shared: false, // don't use the shared instance
    })
    .mount('#app');
```

#### Accessing the shared instance
It's possible to access the shared instance via code as well:

```js
import { I18n } from 'laravel-vue-i18n'

I18n.getSharedInstance()
```

## Caveats

It is possible to import a translation function before installing the `i18nVue` plugin (this is alos the current behavior, nothing new here). When calling the translation function, ie `trans()`, and the plugin has not been installed, the shared instance will be created with default options. This ensures that it's possible to import and call these functions without any fatal errors (also current behavior). When installing the plugin, and a shared instance exists, the options for this instance will be updated with the ones provided by the installer (assuming the `shared: false` flag was not used).


Patch:
diff --git a/README.md b/README.md
index 3866567..2adb82a 100644
--- a/README.md
+++ b/README.md
@@ -148,8 +148,9 @@ mix.i18n();
 ### Plugin Options
 
 - `lang` *(optional)*: If not provided it will try to find from the `<html lang="pt">` tag.
-- `fallbackLang` *(optional): If the `lang` was not provided or is invalid, it will try reach for this `fallbackLang` instead, default is: `en`.
+- `fallbackLang` *(optional)*: If the `lang` was not provided or is invalid, it will try reach for this `fallbackLang` instead, default is: `en`.
 - `resolve` *(required)*: The way to reach your language files.
+- `shared` *(optional)*: whether to [share the same `I18n` instance](#using-multiple-instances) between different Vue apps. Defaults to `true`.
 
 ```js
 createApp().use(i18nVue, {
@@ -193,7 +194,7 @@ setup() {
     return {
         welcomeLabel: wTrans('Welcome!'),
         welcomeFrancisco: wTrans('Welcome, :name!', { name: 'Francisco' })
-    } 
+    }
 }
 
 <template>
@@ -243,7 +244,7 @@ setup() {
     return {
         oneAppleLabel: wTransChoice('There is one apple|There are many apples', 1),
         multipleApplesLabel: wTransChoice('{0} There are none|[1,19] There are some|[20,*] There are many', 19)
-    } 
+    }
 }
 
 <template>
@@ -287,3 +288,78 @@ import { isLoaded } from 'laravel-vue-i18n';
 const loaded = isLoaded(); // true
 const loaded = isLoaded('fr'); // false
 ```
+
+## Using multiple instances
+
+Under the hood, the Vue plugin is using a `I18n` class which encapsulates all the translation logic and the currently active language. This means that it's possible to create multiple class instances, each with different options and active language. This can be useful for scenarios where part of the app needs to be translated to a language different from the main UI.
+
+Note that loaded languages are still shared between different instances. This avoids loading the same set of translations multiple times. The main difference between different instances will be the currently active language.
+
+```js
+import { I18n } from 'laravel-vue-i18n'
+
+const resolver = lang => import(`./fixtures/lang/${lang}.json`)
+
+const i18nEn = new I18n({
+    lang: 'en',
+    resolve: resolver
+})
+const i18nPt = new I18n({
+    lang: 'pt',
+    resolve: resolver
+})
+
+i18nEn.trans('Welcome!') // will output "Welcome!"
+i18nPt.trans('Welcome!') // will output "Bem-vindo!"
+```
+
+By default, installing the the `i18nVue` plugin will create a shared instance. This instance is accessed when importing the translation functions, such as `trans`, directly. When using multiple Vue app instances, it's possible to either share the `I18n` instance between them, or have each app create its own instance.
+
+**Shared usage (default)** - all Vue app instances will use the same `I18n` class and currently active language:
+```js
+import { i18nVue } from 'laravel-vue-i18n'
+
+const appA = createApp()
+    .use(i18nVue, { lang: 'pt' })
+    .mount('#app-1');
+
+const appB = createApp()
+    .use(i18nVue)
+    .mount('#app-2');
+
+// elsewhere
+import { trans } from 'laravel-vue-i18n'
+
+trans('Welcome!') // will output "Bem-vindo!"
+```
+
+**Non-shared usage** - each Vue app will have its own `I18n` instance & currently active language.
+```js
+import { i18nVue } from 'laravel-vue-i18n'
+
+const appA = createApp()
+    .use(i18nVue, {
+        lang: 'es'
+        shared: false, // don't use the shared instance
+    })
+    .mount('#app-1');
+
+const appB = createApp()
+    .use(i18nVue, {
+        lang: 'pt'
+        shared: false, // don't use the shared instance
+    })
+    .mount('#app-2');
+```
+
+### Accessing the shared instance
+It's possible to access the shared instance via code as well:
+
+```js
+import { I18n } from 'laravel-vue-i18n'
+
+I18n.getSharedInstance()
+```
+### Caveats
+
+It is possible to import a translation function before installing the `i18nVue` plugin. When calling the translation function, ie `trans()`, and the plugin has not been installed, a shared `I18n` instance will be created with default options. This ensures that it's possible to import and call these functions without any fatal errors. However, this may yield undesired results. Therefore, it is advisable to never call any translation methods before the plugin is installed.
diff --git a/src/index.ts b/src/index.ts
index 84440ab..0ca255d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,6 @@
 import { reactive, Plugin, computed, ComputedRef } from 'vue'
 import { OptionsInterface } from './interfaces/options'
+import { PluginOptionsInterface } from './interfaces/plugin-options'
 import { LanguageInterface } from './interfaces/language'
 import { LanguageJsonFileInterface } from './interfaces/language-json-file'
 import { ReplacementsInterface } from './interfaces/replacements'
@@ -10,7 +11,12 @@ import { hasPhpTranslations } from './utils/has-php-translations'
 const isServer = typeof window === 'undefined'
 
 /**
- * The default options, for the plugin.
+ * Stores the shared i18n class instance
+ */
+let sharedInstance: I18n = null
+
+/**
+ * The default options, for the I18n class
  */
 const DEFAULT_OPTIONS: OptionsInterface = {
   lang: !isServer && document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : null,
@@ -19,112 +25,45 @@ const DEFAULT_OPTIONS: OptionsInterface = {
 }
 
 /**
- * Stores the current options.
- */
-let options: OptionsInterface = DEFAULT_OPTIONS
-
-/**
- * Stores the loaded languages.
- */
-let loaded: LanguageInterface[] = []
-
-/**
- * The active messages to use.
+ * The default options, for the plugin.
  */
-const activeMessages: object = reactive({})
+const DEFAULT_PLUGIN_OPTIONS: PluginOptionsInterface = {
+  shared: true
+}
 
 /**
  * Checks if the language is loaded.
  */
 export function isLoaded(lang?: string): boolean {
-  lang ??= getActiveLanguage()
-
-  return loaded.some((row) => row.lang.replace(/[-_]/g, '-') === lang.replace(/[-_]/g, '-'))
-}
-
-/**
- * Loads the language async.
- */
-function loadLanguage(lang: string, dashLangTry: boolean = false): void {
-  const loadedLang: LanguageInterface = loaded.find((row) => row.lang === lang)
-
-  if (loadedLang) {
-    setLanguage(loadedLang)
-
-    return
-  }
-
-  const { default: messages } = resolveLang(options.resolve, lang)
-
-  applyLanguage(lang, messages, dashLangTry, loadLanguage)
+  return I18n.getSharedInstance().isLoaded(lang)
 }
 
 /**
  * Loads the language file.
  */
 export function loadLanguageAsync(lang: string, dashLangTry = false): Promise<string | void> {
-  const loadedLang: LanguageInterface = loaded.find((row) => row.lang === lang)
-
-  if (loadedLang) {
-    return Promise.resolve(setLanguage(loadedLang))
-  }
-
-  return resolveLangAsync(options.resolve, lang).then(({ default: messages }) =>
-    applyLanguage(lang, messages, dashLangTry, loadLanguageAsync)
-  )
-}
-
-/**
- * Applies the language data and saves it to the loaded storage.
- */
-function applyLanguage(
-  lang: string,
-  messages: { [key: string]: string },
-  dashLangTry: boolean = false,
-  callable: Function
-): string {
-  if (Object.keys(messages).length < 1) {
-    if (/[-_]/g.test(lang) && !dashLangTry) {
-      return callable(
-        lang.replace(/[-_]/g, (char) => (char === '-' ? '_' : '-')),
-        true
-      )
-    }
-
-    if (lang !== options.fallbackLang) {
-      return callable(options.fallbackLang)
-    }
-  }
-
-  const data: LanguageInterface = { lang, messages }
-  loaded.push(data)
-
-  return setLanguage(data)
+  return I18n.getSharedInstance().loadLanguageAsync(lang, dashLangTry)
 }
 
 /**
  * Get the translation for the given key.
  */
 export function trans(key: string, replacements: ReplacementsInterface = {}): string {
-  return wTrans(key, replacements).value
+  return I18n.getSharedInstance().trans(key, replacements)
 }
 
 /**
  * Get the translation for the given key and watch for any changes.
  */
 export function wTrans(key: string, replacements: ReplacementsInterface = {}): ComputedRef<string> {
-  if (!activeMessages[key]) {
-    activeMessages[key] = key
-  }
-
-  return computed(() => makeReplacements(activeMessages[key], replacements))
+  return I18n.getSharedInstance().wTrans(key, replacements)
 }
 
 /**
  * Translates the given message based on a count.
  */
 export function transChoice(key: string, number: number, replacements: ReplacementsInterface = {}): string {
-  return wTransChoice(key, number, replacements).value
+  return I18n.getSharedInstance().transChoice(key, number, replacements)
 }
 
 /**
@@ -135,148 +74,303 @@ export function wTransChoice(
   number: number,
   replacements: ReplacementsInterface = {}
 ): ComputedRef<string> {
-  const message = wTrans(key, replacements)
-
-  replacements.count = number.toString()
-
-  return computed(() => makeReplacements(choose(message.value, number, options.lang), replacements))
+  return I18n.getSharedInstance().wTransChoice(key, number, replacements)
 }
 
 /**
  * Returns the current active language.
  */
 export function getActiveLanguage(): string {
-  return options.lang || options.fallbackLang
+  return I18n.getSharedInstance().getActiveLanguage()
 }
 
 /**
- * Sets the language messages to the activeMessages.
+ * Resets all the data stored in memory.
  */
-function setLanguage({ lang, messages }: LanguageInterface): string {
-  if (!isServer) {
-    // When setting the HTML lang attribute, hyphen must be use instead of underscore.
-    document.documentElement.setAttribute('lang', lang.replace('_', '-'))
-  }
+export const reset = (): void => {
+  sharedInstance?.reset() // avoid creating a shared instance here
+}
 
-  options.lang = lang
+/**
+ * Alias to `transChoice` to mimic the same function name from Laravel Framework.
+ */
+export const trans_choice = transChoice
 
-  for (const [key, value] of Object.entries(messages)) {
-    activeMessages[key] = value
-  }
+/**
+ * The Vue Plugin. to be used on your Vue app like this: `app.use(i18nVue)`
+ */
+export const i18nVue: Plugin = {
+  install(app, options: PluginOptionsInterface = {}) {
+    options = { ...DEFAULT_PLUGIN_OPTIONS, ...options }
 
-  for (const [key] of Object.entries(activeMessages)) {
-    if (!messages[key]) {
-      activeMessages[key] = null
-    }
-  }
+    const i18n = options.shared ? I18n.getSharedInstance(options) : new I18n(options)
+
+    app.config.globalProperties.$t = (key: string, replacements: ReplacementsInterface) => i18n.trans(key, replacements)
+    app.config.globalProperties.$tChoice = (key: string, number: number, replacements: ReplacementsInterface) =>
+      i18n.transChoice(key, number, replacements)
 
-  return lang
+    app.provide('i18n', i18n)
+  }
 }
 
 /**
- * It resolves the language file or data, from direct data, syncrone.
+ * The I18n class. Encapsulates all language loading and translation logic.
  */
-function resolveLang(
-  callable: Function,
-  lang: string,
-  data: { [key: string]: string } = {}
-): LanguageJsonFileInterface {
-  if (!Object.keys(data).length) {
-    data = avoidException(callable, lang)
+export class I18n {
+  /**
+   * Stores the loaded languages.
+   */
+  private static loaded: LanguageInterface[] = []
+
+  // Stores options for the current instance
+  private options: OptionsInterface
+
+  // Stores messages for the currently active language
+  private activeMessages: object = reactive({})
+
+  /**
+   * Creates a new instance of the I18n class, applying default options
+   */
+  constructor(options: OptionsInterface = {}) {
+    this.options = { ...DEFAULT_OPTIONS, ...options }
+
+    this[isServer ? 'loadLanguage' : 'loadLanguageAsync'](this.getActiveLanguage())
   }
 
-  if (hasPhpTranslations(isServer)) {
-    return {
-      default: {
-        ...data,
-        ...avoidException(callable, `php_${lang}`)
-      }
+  /**
+   * Sets options on the instance, preserving any values not present in new options
+   */
+  setOptions(options: OptionsInterface = {}): I18n {
+    this.options = { ...this.options, ...options }
+
+    return this
+  }
+
+  /**
+   * Loads the language async.
+   */
+  loadLanguage(lang: string, dashLangTry: boolean = false): void {
+    const loadedLang: LanguageInterface = I18n.loaded.find((row) => row.lang === lang)
+
+    if (loadedLang) {
+      this.setLanguage(loadedLang)
+
+      return
     }
+
+    const { default: messages } = this.resolveLang(this.options.resolve, lang)
+
+    this.applyLanguage(lang, messages, dashLangTry, this.loadLanguage)
   }
 
-  return { default: data }
-}
+  /**
+   * Loads the language file.
+   */
+  async loadLanguageAsync(lang: string, dashLangTry = false): Promise<string | void> {
+    const loadedLang: LanguageInterface = I18n.loaded.find((row) => row.lang === lang)
 
-/**
- * It resolves the language file or data, from direct data, require or Promise.
- */
-async function resolveLangAsync(callable: Function, lang: string): Promise<LanguageJsonFileInterface> {
-  let data = avoidException(callable, lang)
+    if (loadedLang) {
+      return Promise.resolve(this.setLanguage(loadedLang))
+    }
 
-  if (!(data instanceof Promise)) {
-    return resolveLang(callable, lang, data)
+    const { default: messages } = await this.resolveLangAsync(this.options.resolve, lang)
+    return this.applyLanguage(lang, messages, dashLangTry, this.loadLanguageAsync)
   }
 
-  if (hasPhpTranslations(isServer)) {
-    const phpLang = await avoidExceptionOnPromise(callable(`php_${lang}`))
-    const jsonLang = await avoidExceptionOnPromise(data)
+  /**
+   * Resolves the language file or data, from direct data, synchronously.
+   */
+  resolveLang(callable: Function, lang: string, data: { [key: string]: string } = {}): LanguageJsonFileInterface {
+    if (!Object.keys(data).length) {
+      data = avoidException(callable, lang)
+    }
 
-    return new Promise((resolve) =>
-      resolve({
+    if (hasPhpTranslations(isServer)) {
+      return {
         default: {
-          ...phpLang,
-          ...jsonLang
+          ...data,
+          ...avoidException(callable, `php_${lang}`)
         }
+      }
+    }
+
+    return { default: data }
+  }
+
+  /**
+   * It resolves the language file or data, from direct data, require or Promise.
+   */
+  async resolveLangAsync(callable: Function, lang: string): Promise<LanguageJsonFileInterface> {
+    let data = avoidException(callable, lang)
+
+    if (!(data instanceof Promise)) {
+      return this.resolveLang(callable, lang, data)
+    }
+
+    if (hasPhpTranslations(isServer)) {
+      const phpLang = await avoidExceptionOnPromise(callable(`php_${lang}`))
+      const jsonLang = await avoidExceptionOnPromise(data)
+
+      return new Promise((resolve) =>
+        resolve({
+          default: {
+            ...phpLang,
+            ...jsonLang
+          }
+        })
+      )
+    }
+
+    return new Promise(async (resolve) =>
+      resolve({
+        default: await avoidExceptionOnPromise(data)
       })
     )
   }
 
-  return new Promise(async (resolve) =>
-    resolve({
-      default: await avoidExceptionOnPromise(data)
-    })
-  )
-}
+  /**
+   * Applies the language data and saves it to the loaded storage.
+   */
+  applyLanguage(
+    lang: string,
+    messages: { [key: string]: string },
+    dashLangTry: boolean = false,
+    callable: Function
+  ): string {
+    if (Object.keys(messages).length < 1) {
+      if (/[-_]/g.test(lang) && !dashLangTry) {
+        return callable.call(
+          this,
+          lang.replace(/[-_]/g, (char) => (char === '-' ? '_' : '-')),
+          true
+        )
+      }
 
-/**
- * Make the place-holder replacements on a line.
- */
-function makeReplacements(message: string, replacements?: ReplacementsInterface): string {
-  const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1)
+      if (lang !== this.options.fallbackLang) {
+        return callable.call(this, this.options.fallbackLang)
+      }
+    }
 
-  Object.entries(replacements || []).forEach(([key, value]) => {
-    value = value.toString()
+    const data: LanguageInterface = { lang, messages }
+    I18n.loaded.push(data)
 
-    message = message
-      .replace(`:${key}`, value)
-      .replace(`:${key.toUpperCase()}`, value.toUpperCase())
-      .replace(`:${capitalize(key)}`, capitalize(value))
-  })
+    return this.setLanguage(data)
+  }
 
-  return message
-}
+  /**
+   * Sets the language messages to the activeMessages.
+   */
+  setLanguage({ lang, messages }: LanguageInterface): string {
+    if (!isServer) {
+      // When setting the HTML lang attribute, hyphen must be use instead of underscore.
+      document.documentElement.setAttribute('lang', lang.replace('_', '-'))
+    }
 
-/**
- * Resets all the data stored in memory.
- */
-export const reset = (): void => {
-  loaded = []
-  options = DEFAULT_OPTIONS
+    this.options.lang = lang
+
+    for (const [key, value] of Object.entries(messages)) {
+      this.activeMessages[key] = value
+    }
 
-  for (const [key] of Object.entries(activeMessages)) {
-    activeMessages[key] = null
+    for (const [key] of Object.entries(this.activeMessages)) {
+      if (!messages[key]) {
+        this.activeMessages[key] = null
+      }
+    }
+
+    return lang
   }
-}
 
-/**
- * Alias to `transChoice` to mimic the same function name from Laravel Framework.
- */
-export const trans_choice = transChoice
+  /**
+   * Returns the current active language.
+   */
+  getActiveLanguage(): string {
+    return this.options.lang || this.options.fallbackLang
+  }
 
-/**
- * The Vue Plugin. to be used on your Vue app like this: `app.use(i18nVue)`
- */
-export const i18nVue: Plugin = {
-  install: (app, currentOptions: OptionsInterface = {}) => {
-    options = { ...options, ...currentOptions }
-    app.config.globalProperties.$t = (key: string, replacements: ReplacementsInterface) => trans(key, replacements)
-    app.config.globalProperties.$tChoice = (key: string, number: number, replacements: ReplacementsInterface) =>
-      transChoice(key, number, replacements)
+  /**
+   * Checks if the language is loaded.
+   */
+  isLoaded(lang?: string): boolean {
+    lang ??= this.getActiveLanguage()
+
+    return I18n.loaded.some((row) => row.lang.replace(/[-_]/g, '-') === lang.replace(/[-_]/g, '-'))
+  }
+
+  /**
+   * Get the translation for the given key.
+   */
+  trans(key: string, replacements: ReplacementsInterface = {}): string {
+    return this.wTrans(key, replacements).value
+  }
 
-    if (isServer) {
-      loadLanguage(options.lang || options.fallbackLang)
-    } else {
-      loadLanguageAsync(options.lang || options.fallbackLang)
+  /**
+   * Get the translation for the given key and watch for any changes.
+   */
+  wTrans(key: string, replacements: ReplacementsInterface = {}): ComputedRef<string> {
+    if (!this.activeMessages[key]) {
+      this.activeMessages[key] = key
     }
+
+    return computed(() => this.makeReplacements(this.activeMessages[key], replacements))
+  }
+
+  /**
+   * Translates the given message based on a count.
+   */
+  transChoice(key: string, number: number, replacements: ReplacementsInterface = {}): string {
+    return this.wTransChoice(key, number, replacements).value
+  }
+
+  /**
+   * Translates the given message based on a count and watch for changes.
+   */
+  wTransChoice(key: string, number: number, replacements: ReplacementsInterface = {}): ComputedRef<string> {
+    const message = this.wTrans(key, replacements)
+
+    replacements.count = number.toString()
+
+    return computed(() => this.makeReplacements(choose(message.value, number, this.options.lang), replacements))
+  }
+
+  /**
+   * Make the place-holder replacements on a line.
+   */
+  makeReplacements(message: string, replacements?: ReplacementsInterface): string {
+    const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
+
+    Object.entries(replacements || []).forEach(([key, value]) => {
+      value = value.toString()
+
+      message = message
+        .replace(`:${key}`, value)
+        .replace(`:${key.toUpperCase()}`, value.toUpperCase())
+        .replace(`:${capitalize(key)}`, capitalize(value))
+    })
+
+    return message
+  }
+
+  /**
+   * Resets all the data stored in memory.
+   */
+  reset = (): void => {
+    I18n.loaded = []
+    this.options = DEFAULT_OPTIONS
+
+    for (const [key] of Object.entries(this.activeMessages)) {
+      this.activeMessages[key] = null
+    }
+
+    if (this === sharedInstance) {
+      sharedInstance = null
+    }
+  }
+
+  /**
+   * Gets the shared I18n instance, instantiating it if not yet created
+   */
+  static getSharedInstance(options?: OptionsInterface): I18n {
+    return sharedInstance?.setOptions(options) || (sharedInstance = new I18n(options))
   }
 }
diff --git a/src/interfaces/plugin-options.ts b/src/interfaces/plugin-options.ts
new file mode 100644
index 0000000..9adfbd9
--- /dev/null
+++ b/src/interfaces/plugin-options.ts
@@ -0,0 +1,8 @@
+import { OptionsInterface } from './options'
+
+/**
+ * The Interface that is responsible for the Options provided.
+ */
+export interface PluginOptionsInterface extends OptionsInterface {
+  shared?: boolean
+}


Test patch:
diff --git a/test/class.test.ts b/test/class.test.ts
new file mode 100644
index 0000000..525931f
--- /dev/null
+++ b/test/class.test.ts
@@ -0,0 +1,70 @@
+import { I18n } from '../src'
+
+it('can update options and get the active language', async () => {
+    const i18n = new I18n({ lang: 'en '})
+
+    i18n.setOptions({ lang: 'et' })
+
+    expect(i18n.getActiveLanguage()).toBe('et')
+})
+
+it('can set the active language and its messages', async () => {
+    const i18n = new I18n()
+
+    i18n.setLanguage({ lang: 'pt', messages: { 'Welcome!': 'Bem-vindo!' }})
+
+    expect(i18n.getActiveLanguage()).toBe('pt')
+    expect(i18n.trans('Welcome!')).toBe('Bem-vindo!')
+})
+
+it('does not share state between different instances', async () => {
+    const enI18n = new I18n()
+    const ptI18n = new I18n()
+
+    enI18n.setLanguage({ lang: 'en', messages: { 'Welcome!': 'Welcome!' }})
+    ptI18n.setLanguage({ lang: 'pt', messages: { 'Welcome!': 'Bem-vindo!' }})
+
+    expect(enI18n.getActiveLanguage()).toBe('en')
+    expect(enI18n.trans('Welcome!')).toBe('Welcome!')
+
+    expect(ptI18n.getActiveLanguage()).toBe('pt')
+    expect(ptI18n.trans('Welcome!')).toBe('Bem-vindo!')
+})
+
+it('allows creating a shared instance', async () => {
+    const shared1 = I18n.getSharedInstance()
+    const shared2 = I18n.getSharedInstance()
+
+    expect(shared1).toBeInstanceOf(I18n)
+    expect(shared2).toBe(shared1)
+})
+
+it('allows creating a shared instance with options', async () => {
+    const shared = I18n.getSharedInstance({ lang: 'pt' })
+
+    expect(shared.getActiveLanguage()).toBe('pt')
+})
+
+it('allows updating options when getting a shared instance', async () => {
+    const shared = I18n.getSharedInstance({ lang: 'en' })
+
+    I18n.getSharedInstance({ lang: 'pt' })
+
+    expect(shared.getActiveLanguage()).toBe('pt')
+})
+
+it('allows resetting all data', async () => {
+    const i18n = new I18n({
+        resolve: lang => import(`./fixtures/lang/${lang}.json`)
+    })
+
+    await i18n.loadLanguageAsync('pt')
+
+    expect(i18n.getActiveLanguage()).toBe('pt')
+    expect(i18n.trans('Welcome!')).toBe('Bem-vindo!')
+
+    i18n.reset()
+
+    expect(i18n.getActiveLanguage()).toBe('en')
+    expect(i18n.trans('Welcome!')).toBe('Welcome!')
+})