Repository: xiCO2k/laravel-vue-i18n
Pull request #103 (2023-02-24 15:18:02)

Fix fallback locale
Fix for [Issue 87: FallbackLocale not working](https://github.com/xiCO2k/laravel-vue-i18n/issues/87)

fallback locale is loaded first (in browser mode)
if active message has no value or the value equals with the key then use fallback locale translation

xiCO2k: Thanks for the contribution, can you please add a test to make sure it works?

Patch:
diff --git a/src/index.ts b/src/index.ts
index 635032f..a97108b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -21,6 +21,7 @@ let sharedInstance: I18n = null
 const DEFAULT_OPTIONS: OptionsInterface = {
   lang: !isServer && document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : null,
   fallbackLang: 'en',
+  fallbackMissingTranslations: false,
   resolve: (lang: string) => new Promise((resolve) => resolve({ default: {} })),
   onLoad: (lang: string) => {}
 }
@@ -129,6 +130,9 @@ export class I18n {
   // Stores messages for the currently active language
   private activeMessages: object = reactive({})
 
+  // Stores messages for fallback language
+  private fallbackMessages: object = reactive({})
+
   // Stores the abort controller for the load promises.
   private abortController: AbortController
 
@@ -137,8 +141,11 @@ export class I18n {
    */
   constructor(options: OptionsInterface = {}) {
     this.options = { ...DEFAULT_OPTIONS, ...options }
-
-    this.load()
+    if (this.options.fallbackMissingTranslations) {
+      this.loadFallbackLanguage()
+    } else {
+      this.load()
+    }
   }
 
   /**
@@ -161,6 +168,32 @@ export class I18n {
     this[isServer ? 'loadLanguage' : 'loadLanguageAsync'](this.getActiveLanguage())
   }
 
+  /**
+   * Load fallback language
+   */
+  loadFallbackLanguage(): void {
+    if (!isServer) {
+      this.resolveLangAsync(this.options.resolve, this.options.fallbackLang).then(({ default: messages }) => {
+        for (const [key, value] of Object.entries(messages)) {
+          this.fallbackMessages[key] = value
+        }
+        const lang = this.options.fallbackLang
+        const data: LanguageInterface = { lang, messages }
+        I18n.loaded.push(data)
+        this.load()
+      })
+    } else {
+      const { default: messages } = this.resolveLang(this.options.resolve, this.options.fallbackLang)
+      for (const [key, value] of Object.entries(messages)) {
+        this.fallbackMessages[key] = value
+      }
+      const lang = this.options.fallbackLang
+      const data: LanguageInterface = { lang, messages }
+      I18n.loaded.push(data)
+      this.loadLanguage(this.getActiveLanguage())
+    }
+  }
+
   /**
    * Loads the language async.
    */
@@ -300,8 +333,14 @@ export class I18n {
       this.activeMessages[key] = value
     }
 
+    for (const [key, value] of Object.entries(this.fallbackMessages)) {
+      if (!this.activeMessages[key] || this.activeMessages[key] === key) {
+        this.activeMessages[key] = value
+      }
+    }
+
     for (const [key] of Object.entries(this.activeMessages)) {
-      if (!messages[key]) {
+      if (!messages[key] && !this.fallbackMessages[key]) {
         this.activeMessages[key] = null
       }
     }
@@ -385,9 +424,9 @@ export class I18n {
       value = value.toString()
 
       message = message
-        .replace(`:${key}`, value)
-        .replace(`:${key.toUpperCase()}`, value.toUpperCase())
-        .replace(`:${capitalize(key)}`, capitalize(value))
+        .replace(new RegExp(`:${key}`, 'g'), value)
+        .replace(new RegExp(`:${key.toUpperCase()}`, 'g'), value.toUpperCase())
+        .replace(new RegExp(`:${capitalize(key)}`, 'g'), capitalize(value))
     })
 
     return message
diff --git a/src/interfaces/options.ts b/src/interfaces/options.ts
index da4dd00..61cd3ce 100644
--- a/src/interfaces/options.ts
+++ b/src/interfaces/options.ts
@@ -6,6 +6,7 @@ import { LanguageJsonFileInterface } from './language-json-file'
 export interface OptionsInterface {
   lang?: string
   fallbackLang?: string
+  fallbackMissingTranslations?: boolean
   resolve?(lang: string): Promise<LanguageJsonFileInterface>
   onLoad?: (lang: string) => void
 }


Test patch:
diff --git a/test/class.test.ts b/test/class.test.ts
index 0b24129..ddeb7cd 100644
--- a/test/class.test.ts
+++ b/test/class.test.ts
@@ -84,3 +84,21 @@ it('calls onLoad when loaded', async () => {
     expect(onLoadFunction).toHaveBeenCalledWith('en')
     expect(onLoadFunction).toHaveBeenCalledWith('pt')
 })
+
+it('can override missing translations with fallback language translations', async () => {
+    const onLoadFunction = jest.fn()
+    const i18n = new I18n({
+        fallbackLang: 'en',
+        fallbackMissingTranslations: true,
+        resolve: lang => import(`./fixtures/lang/${lang}.json`),
+        onLoad: onLoadFunction
+    })
+    await i18n.loadLanguageAsync('pt')
+
+    expect(onLoadFunction).toHaveBeenCalledTimes(1)
+
+    expect(i18n.getActiveLanguage()).toBe('pt')
+    expect(i18n.trans('Welcome!')).toBe('Bem-vindo!')
+
+    expect(i18n.trans('English only.')).toBe('English only.')
+})
\ No newline at end of file
diff --git a/test/fixtures/lang/en.json b/test/fixtures/lang/en.json
index 1bf8abb..72c35e5 100644
--- a/test/fixtures/lang/en.json
+++ b/test/fixtures/lang/en.json
@@ -4,5 +4,6 @@
     "Only Available on EN": "Only Available on EN",
     "{1} :count minute ago|[2,*] :count minutes ago": "{1} :count minute ago|[2,*] :count minutes ago",
     "Start/end": "Start/End",
-    "Get started.": "Get started."
+    "Get started.": "Get started.",
+    "English only.": "English only."
 }
diff --git a/test/fixtures/lang/pt.json b/test/fixtures/lang/pt.json
index 9c15cf0..4afd8fe 100644
--- a/test/fixtures/lang/pt.json
+++ b/test/fixtures/lang/pt.json
@@ -1,6 +1,7 @@
 {
     "Welcome!": "Bem-vindo!",
     "Welcome, :name!": "Bem-vindo, :name!",
+    "hi :name, hi :name": "olá :name, olá :name",
     "{1} :count minute ago|[2,*] :count minutes ago": "{1} há :count minuto|[2,*] há :count minutos",
     "foo.bar": "baz",
     "Start/end": "Início/Fim",
diff --git a/test/setup.ts b/test/setup.ts
index aa506d0..3d616f9 100644
--- a/test/setup.ts
+++ b/test/setup.ts
@@ -2,12 +2,13 @@ import { mount } from '@vue/test-utils'
 import { i18nVue } from '../src'
 import { parseAll } from '../src/loader'
 
-global.mountPlugin = async (template = '<div />', lang = 'pt', fallbackLang = 'pt') => {
+global.mountPlugin = async (template = '<div />', lang = 'pt', fallbackLang = 'pt', fallbackMissingTranslations = false) => {
   const wrapper = mount({ template }, {
     global: {
       plugins: [[i18nVue, {
         lang,
         fallbackLang,
+        fallbackMissingTranslations,
         resolve: lang => import(`./fixtures/lang/${lang}.json`),
       }]]
     }
diff --git a/test/translate.test.ts b/test/translate.test.ts
index 80d8a46..3b25dd8 100644
--- a/test/translate.test.ts
+++ b/test/translate.test.ts
@@ -47,6 +47,13 @@ it('fallback to the `fallbackLang` if the `lang` was not found', async () => {
   expect(trans('Welcome!')).toBe('Bem-vindo!');
 });
 
+it('fallback individual translation entries to the `fallbackLang` if translation was not found in the active language', async () => {
+  await global.mountPlugin(`<div />`, 'pt', 'en', 'true');
+
+  expect(trans('Welcome!')).toBe('Bem-vindo!');
+  expect(trans('English only.')).toBe('English only.');
+});
+
 it('returns the given key if the key is not available on the lang', async () => {
   await global.mountPlugin(`<div />`, 'en');
   expect(trans('Only Available on EN')).toBe('Only Available on EN');
@@ -61,6 +68,12 @@ it('translates key with values with $t mixin', async () => {
   expect(wrapper.html()).toBe('<h1>Bem-vindo, Francisco!</h1>')
 })
 
+it('replaces multiple occurrences with $t mixin', async () => {
+  const wrapper = await global.mountPlugin(`<h1>{{ $t('hi :name, hi :name', { name: 'Francisco' }) }}</h1>`);
+
+    expect(wrapper.html()).toBe('<h1>olá Francisco, olá Francisco</h1>')
+})
+
 it('translates key with values with "trans" helper', async () => {
   await global.mountPlugin();
 
@@ -68,6 +81,13 @@ it('translates key with values with "trans" helper', async () => {
     .toBe('Bem-vindo, Francisco!')
 })
 
+it('replaces multiple occurrences with "trans" helper', async () => {
+  await global.mountPlugin();
+
+  expect(trans('hi :name, hi :name', { name: 'Francisco' }))
+    .toBe('olá Francisco, olá Francisco')
+})
+
 it('loads a lang', async () => {
   const wrapper = await global.mountPlugin(`<h1>{{ $t('Welcome, :name!', { name: 'Francisco' }) }}</h1>`, 'en');
 
@@ -204,4 +224,4 @@ it('does not translate existing strings which contain delimiter symbols', async
 
   expect(trans('Start/end')).toBe('Início/Fim');
   expect(trans('Get started.')).toBe('Comece.');
-})
\ No newline at end of file
+})