Repository: nuxt-community/i18n-module
Pull request #965 (2020-11-19 02:12:45)

fix(detectBrowserLanguage): use ISO code if available
This PR updates `matchBrowserLocale` to use the ISO code for a locale when performing browser language detection. If the ISO code isn't set then the usual locale code is used, as is the current functionality.

This solves some of the issues mentioned in #499 where one may not want to use ISO codes in the URL, but does wish to make use of the browser detection and SEO features.

This is my first attempt at contributing to this project so I'm not 100% sure that the way I've gone about it is correct and I would welcome feedback on how this PR can be made better :)

rchl: Sorry to keep you waiting. I haven't reviewed it properly yet, will do it ASAP, when I have more time.
rchl: This would need some new tests added around here: https://github.com/nuxt-community/i18n-module/blob/fee0e9375ef050e05ffc9a29d69fe0216fe50e03/test/module.test.js#L1166-L1192

Base config used by those tests is here: https://github.com/nuxt-community/i18n-module/blob/fee0e9375ef050e05ffc9a29d69fe0216fe50e03/test/fixture/base.config.js#L33-L44
rchl: Actually, tests can be unit-tested here: https://github.com/nuxt-community/i18n-module/blob/6bac69c1cd3ca51f0ab675460a63be4509d19c01/test/unit.test.js#L31-L31
It's easier and faster to work with those.
owenandrews: Thanks @rchl, I added a unit test, does it need to be comprehensive or is just the one ok in this instance?
rchl: Can you also update the `options-reference.md` (https://i18n.nuxtjs.org/options-reference#locales) to mention that the `iso` property is also used for matching browser locales when using the `detectBrowserLanguage` option? If you do, please update both `en` and `es` pages (both with English text).

Patch:
diff --git a/docs/content/en/browser-language-detection.md b/docs/content/en/browser-language-detection.md
index b21d26a2..3ba666bd 100644
--- a/docs/content/en/browser-language-detection.md
+++ b/docs/content/en/browser-language-detection.md
@@ -14,7 +14,7 @@ By default, **nuxt-i18n** attempts to redirect users to their preferred language
   detectBrowserLanguage: {
     useCookie: true,
     cookieKey: 'i18n_redirected',
-    // onlyOnRoot: true,
+    onlyOnRoot: true,  // recommended
   }
 }]
 ```
@@ -25,7 +25,7 @@ For better SEO, it's recommended to set `onlyOnRoot` to `true`. With it set, the
 
 </alert>
 
-Browser language is detected either from `navigator` when running on client-side, or from the `accept-language` HTTP header. Configured `locales` (or locales `code`s when locales are specified in object form) are matched against locales reported by the browser (for example `en-US,en;q=0.9,no;q=0.8`). If there is no exact match for the full locale, the language code (letters before `-`) are matched against configured locales (for backward-compatibility).
+Browser language is detected either from `navigator` when running on client-side, or from the `accept-language` HTTP header. Configured `locales` (or locales `iso` and/or `code` when locales are specified in object form) are matched against locales reported by the browser (for example `en-US,en;q=0.9,no;q=0.8`). If there is no exact match for the full locale, the language code (letters before `-`) are matched against configured locales.
 
 To prevent redirecting users every time they visit the app, **nuxt-i18n** sets a cookie after the first redirection. You can change the cookie's name by setting `detectBrowserLanguage.cookieKey` option to whatever you'd like, the default is _i18n_redirected_.
 
diff --git a/docs/content/en/options-reference.md b/docs/content/en/options-reference.md
index 2ea92a9f..e05eeca5 100644
--- a/docs/content/en/options-reference.md
+++ b/docs/content/en/options-reference.md
@@ -62,7 +62,7 @@ List of locales supported by your app. Can either be an array of codes (`['en',
 
 When using an object form, the properties can be:
 - `code` (**required**) - unique identifier of the locale
-- `iso` (required when using SEO features) - The ISO code used for SEO features. Should be in one of those formats:
+- `iso` (required when using SEO features and) - The ISO code used for SEO features and for matching browser locales when using [detectBrowserLanguage](#detectBrowserLanguage) functionality. Should be in one of those formats:
   * ISO 639-1 code (e.g. `'en'`)
   * ISO 639-1 and ISO 3166-1 alpha-2 codes, separated by hyphen (e.g. `'en-US'`)
 - `file` (required when using `lazy`) - the name of the file. Will be resolved relative to `langDir` path when loading locale messages lazily
@@ -111,7 +111,8 @@ Directory that contains translation files when lazy-loading messages. This CAN N
 - default: `{ alwaysRedirect: false, fallbackLocale: '', onlyOnRoot: false, useCookie: true, cookieCrossOrigin: false, cookieDomain: null, cookieKey: 'i18n_redirected', cookieSecure: false }`
 
 Enables browser language detection to automatically redirect visitors to their preferred locale as they visit your site for the first time.
-Set to false to disable.
+
+See also [Browser language detection](./browser-language-detection) for a guide.
 
 <alert type="info">
 
@@ -130,6 +131,8 @@ Supported properties:
 - `cookieCrossOrigin` (default: `false`) - When `true`, sets the flags `SameSite=None; Secure` on the cookie to allow cross-domain use of the cookie (required when app is embedded in an iframe).
 - `cookieSecure` (default: `false`) - Sets the `Secure` flag for the cookie.
 
+Set to false to disable.
+
 ## `rootRedirect`
 
 - type: `string` or `object` or `null`
diff --git a/docs/content/es/browser-language-detection.md b/docs/content/es/browser-language-detection.md
index df5a5681..d8f7ebf1 100644
--- a/docs/content/es/browser-language-detection.md
+++ b/docs/content/es/browser-language-detection.md
@@ -15,7 +15,7 @@ Por defecto, **nuxt-i18n** intenta redirigir a los usuarios a su idioma preferid
   detectBrowserLanguage: {
     useCookie: true,
     cookieKey: 'i18n_redirected',
-    // onlyOnRoot: true,
+    onlyOnRoot: true,  // recommended
   }
 }]
 ```
@@ -26,7 +26,7 @@ For better SEO, it's recommended to set `onlyOnRoot` to `true`. With it set, the
 
 </alert>
 
-Browser language is detected either from `navigator` when running on client-side, or from the `accept-language` HTTP header. Configured `locales` (or locales `code`s when locales are specified in object form) are matched against locales reported by the browser (for example `en-US,en;q=0.9,no;q=0.8`). If there is no exact match for the full locale, the language code (letters before `-`) are matched against configured locales (for backward-compatibility).
+Browser language is detected either from `navigator` when running on client-side, or from the `accept-language` HTTP header. Configured `locales` (or locales `iso` and/or `code` when locales are specified in object form) are matched against locales reported by the browser (for example `en-US,en;q=0.9,no;q=0.8`). If there is no exact match for the full locale, the language code (letters before `-`) are matched against configured locales.
 
 Para evitar redirigir a los usuarios cada vez que visitan la aplicación, **nuxt-i18n** establece una cookie después de la primera redirección. Puede cambiar el nombre de la cookie configurando la opción `detectBrowserLanguage.cookieKey` a lo que desee, el valor predeterminado es _i18n_redirected_.
 
diff --git a/docs/content/es/options-reference.md b/docs/content/es/options-reference.md
index eb0f51c1..fa782be2 100644
--- a/docs/content/es/options-reference.md
+++ b/docs/content/es/options-reference.md
@@ -62,7 +62,7 @@ List of locales supported by your app. Can either be an array of codes (`['en',
 
 When using an object form, the properties can be:
 - `code` (**required**) - unique identifier of the locale
-- `iso` (required when using SEO features) - The ISO code used for SEO features. Should be in one of those formats:
+- `iso` (required when using SEO features and) - The ISO code used for SEO features and for matching browser locales when using [detectBrowserLanguage](#detectBrowserLanguage) functionality. Should be in one of those formats:
   * ISO 639-1 code (e.g. `'en'`)
   * ISO 639-1 and ISO 3166-1 alpha-2 codes, separated by hyphen (e.g. `'en-US'`)
 - `file` (required when using `lazy`) - the name of the file. Will be resolved relative to `langDir` path when loading locale messages lazily
@@ -111,7 +111,8 @@ Directory that contains translation files when lazy-loading messages. This CAN N
 - default: `{ alwaysRedirect: false, fallbackLocale: '', onlyOnRoot: false, useCookie: true, cookieCrossOrigin: false, cookieDomain: null, cookieKey: 'i18n_redirected', cookieSecure: false }`
 
 Enables browser language detection to automatically redirect visitors to their preferred locale as they visit your site for the first time.
-Set to false to disable.
+
+See also [Browser language detection](./browser-language-detection) for a guide.
 
 <alert type="info">
 
@@ -130,6 +131,8 @@ Supported properties:
 - `cookieCrossOrigin` (default: `false`) - When `true`, sets the flags `SameSite=None; Secure` on the cookie to allow cross-domain use of the cookie (required when app is embedded in an iframe).
 - `cookieSecure` (default: `false`) - Sets the `Secure` flag for the cookie.
 
+Set to false to disable.
+
 ## `rootRedirect`
 
 - type: `string` or `object` or `null`
diff --git a/src/templates/plugin.main.js b/src/templates/plugin.main.js
index f1bb7931..bb182634 100644
--- a/src/templates/plugin.main.js
+++ b/src/templates/plugin.main.js
@@ -204,9 +204,9 @@ export default async (context) => {
       // Get preferred language from cookie if present and enabled
     } else if (process.client && typeof navigator !== 'undefined' && navigator.languages) {
       // Get browser language either from navigator if running on client side, or from the headers
-      matchedLocale = matchBrowserLocale(localeCodes, navigator.languages)
+      matchedLocale = matchBrowserLocale(locales, navigator.languages)
     } else if (req && typeof req.headers['accept-language'] !== 'undefined') {
-      matchedLocale = matchBrowserLocale(localeCodes, parseAcceptLanguage(req.headers['accept-language']))
+      matchedLocale = matchBrowserLocale(locales, parseAcceptLanguage(req.headers['accept-language']))
     }
 
     const finalLocale = matchedLocale || fallbackLocale
diff --git a/src/templates/utils-common.js b/src/templates/utils-common.js
index 7aa8278e..f0268892 100644
--- a/src/templates/utils-common.js
+++ b/src/templates/utils-common.js
@@ -17,7 +17,7 @@ export const parseAcceptLanguage = input => {
 
 /**
  * Find locale code that best matches provided list of browser locales.
- * @param {string[]} appLocales The user-configured locale codes that are to be matched.
+ * @param {(string[]|Object[])} appLocales The user-configured locale codes that are to be matched.
  * @param {string[]} browserLocales The locales to match against configured.
  * @return {string?}
  */
@@ -25,26 +25,29 @@ export const matchBrowserLocale = (appLocales, browserLocales) => {
   /** @type {{ code: string, score: number }[]} */
   const matchedLocales = []
 
+  // Normalise appLocales input
+  appLocales = appLocales.map(appLocale => ({
+    code: typeof appLocale === 'string' ? appLocale : appLocale.code,
+    iso: typeof appLocale === 'string' ? appLocale : (appLocale.iso || appLocale.code)
+  }))
+
   // First pass: match exact locale.
   for (const [index, browserCode] of browserLocales.entries()) {
-    const matchedCode = appLocales.find(appCode => appCode.toLowerCase() === browserCode.toLowerCase())
-    if (matchedCode) {
-      matchedLocales.push({ code: matchedCode, score: 1 - index / browserLocales.length })
+    const matchedLocale = appLocales.find(appLocale => appLocale.iso.toLowerCase() === browserCode.toLowerCase())
+    if (matchedLocale) {
+      matchedLocales.push({ code: matchedLocale.code, score: 1 - index / browserLocales.length })
       break
     }
   }
 
   // Second pass: match only locale code part of the browser locale (not including country).
   for (const [index, browserCode] of browserLocales.entries()) {
-    if (browserCode.includes('-')) {
-      // For backwards-compatibility, this is lower-cased before comparing.
-      const languageCode = browserCode.split('-')[0].toLowerCase()
-
-      if (appLocales.includes(languageCode)) {
-        // Deduct a thousandth for being non-exact match.
-        matchedLocales.push({ code: languageCode, score: 0.999 - index / browserLocales.length })
-        break
-      }
+    const languageCode = browserCode.split('-')[0].toLowerCase()
+    const matchedLocale = appLocales.find(appLocale => appLocale.iso.split('-')[0].toLowerCase() === languageCode)
+    if (matchedLocale) {
+      // Deduct a thousandth for being non-exact match.
+      matchedLocales.push({ code: matchedLocale.code, score: 0.999 - index / browserLocales.length })
+      break
     }
   }
 


Test patch:
diff --git a/test/unit.test.js b/test/unit.test.js
index 49611608..d9633e81 100644
--- a/test/unit.test.js
+++ b/test/unit.test.js
@@ -94,4 +94,25 @@ describe('matchBrowserLocale', () => {
 
     expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en-gb')
   })
+
+  test('matches ISO locale code', () => {
+    const appLocales = [{ code: 'cn', iso: 'zh-CN' }, { code: 'en', iso: 'en-US' }]
+    const browserLocales = ['zh', 'zh-CN']
+
+    expect(matchBrowserLocale(appLocales, browserLocales)).toBe('cn')
+  })
+
+  test('matches full ISO code', () => {
+    const appLocales = [{ code: 'us', iso: 'en-US' }, { code: 'gb', iso: 'en-GB' }]
+    const browserLocales = ['en-GB', 'en']
+
+    expect(matchBrowserLocale(appLocales, browserLocales)).toBe('gb')
+  })
+
+  test('matches locale when only languages match', () => {
+    const appLocales = ['en-GB', 'en-US']
+    const browserLocales = ['en-IN', 'en']
+
+    expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en-GB')
+  })
 })