diff --git a/docs/content/en/browser-language-detection.md b/docs/content/en/browser-language-detection.md index 50d000af5..b21d26a29 100644 --- a/docs/content/en/browser-language-detection.md +++ b/docs/content/en/browser-language-detection.md @@ -13,17 +13,20 @@ By default, **nuxt-i18n** attempts to redirect users to their preferred language // ... detectBrowserLanguage: { useCookie: true, - cookieKey: 'i18n_redirected' + cookieKey: 'i18n_redirected', + // onlyOnRoot: true, } }] ``` -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, the language code (letters before `-`) are matched against configured locales (for backwards compatibility). +For better SEO, it's recommended to set `onlyOnRoot` to `true`. With it set, the language detection is only attempted when the user visits the root path (`/`) of the site. This allows crawlers to access the requested page rather than being redirected away based on detected locale. It also allows linking to pages in specific locales. +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). + 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_. ```js{}[nuxt.config.js] diff --git a/docs/content/en/options-reference.md b/docs/content/en/options-reference.md index 63c9a9fd4..54be7247b 100644 --- a/docs/content/en/options-reference.md +++ b/docs/content/en/options-reference.md @@ -62,7 +62,7 @@ Here are all the options available when configuring the module and their default // - 'prefix_and_default': add locale prefix for every locale and default strategy: 'prefix_except_default', - // Wether or not the translations should be lazy-loaded, if this is enabled, + // Whether or not the translations should be lazy-loaded, if this is enabled, // you MUST configure langDir option, and locales must be an array of objects, // each containing a file key lazy: false, @@ -79,9 +79,10 @@ Here are all the options available when configuring the module and their default // } rootRedirect: null, - // Enable browser language detection to automatically redirect user - // to their preferred language as they visit your app for the first time - // Set to false to disable + // Enable browser language detection to automatically redirect visitors to their + // preferred language as they visit your site for the first time. + // Note that for better SEO it's recommended to set 'onlyOnRoot' to true. + // Set to false to disable. detectBrowserLanguage: { // If enabled, a cookie is set once a user has been redirected to his // preferred language to prevent subsequent redirections @@ -99,7 +100,10 @@ Here are all the options available when configuring the module and their default // Set to always redirect to value stored in the cookie, not just once alwaysRedirect: false, // If no locale for the browsers locale is a match, use this one as a fallback - fallbackLocale: null + fallbackLocale: null, + // Set to true (recommended for improved SEO) to only attempt to detect browser locale + // on the root path ("/") of the site. Only effective when using strategy other than 'no_prefix'. + onlyOnRoot: false, }, // Set this to true if you're using different domains for each language diff --git a/docs/content/es/browser-language-detection.md b/docs/content/es/browser-language-detection.md index 7af7b41f5..df5a5681e 100644 --- a/docs/content/es/browser-language-detection.md +++ b/docs/content/es/browser-language-detection.md @@ -11,13 +11,23 @@ Por defecto, **nuxt-i18n** intenta redirigir a los usuarios a su idioma preferid ```js{}[nuxt.config.js] ['nuxt-i18n', { + // ... detectBrowserLanguage: { useCookie: true, - cookieKey: 'i18n_redirected' + cookieKey: 'i18n_redirected', + // onlyOnRoot: true, } }] ``` + + +For better SEO, it's recommended to set `onlyOnRoot` to `true`. With it set, the language detection is only attempted when the user visits the root path (`/`) of the site. This allows crawlers to access the requested page rather than being redirected away based on detected locale. It also allows linking to pages in specific locales. + + + +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). + 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_. ```js{}[nuxt.config.js] diff --git a/docs/content/es/options-reference.md b/docs/content/es/options-reference.md index e362f4813..d5677e256 100644 --- a/docs/content/es/options-reference.md +++ b/docs/content/es/options-reference.md @@ -10,6 +10,19 @@ Aquí están todas las opciones disponibles al configurar el módulo y sus valor ```js { // vue-i18n configuration + // See documentation: http://kazupon.github.io/vue-i18n/api/#constructor-options + // To be able to pass more complex configuration options that can't be stringified, it's also + // supported to set this property to a path to a local configuration file. File needs to export + // a function (that will be passed a Nuxt context as a parameter) or plain object. + // Example path: '~/plugins/vue-i18n.js' + // Example file content: + // export default context => { + // return { + // modifiers: { + // snakeCase: (str) => str.split(' ').join('-') + // } + // } + // } vueI18n: {}, // If true, vue-i18n-loader is added to Nuxt's Webpack config @@ -49,7 +62,7 @@ Aquí están todas las opciones disponibles al configurar el módulo y sus valor // - 'prefix_and_default': add locale prefix for every locale and default strategy: 'prefix_except_default', - // Wether or not the translations should be lazy-loaded, if this is enabled, + // Whether or not the translations should be lazy-loaded, if this is enabled, // you MUST configure langDir option, and locales must be an array of objects, // each containing a file key lazy: false, @@ -66,9 +79,10 @@ Aquí están todas las opciones disponibles al configurar el módulo y sus valor // } rootRedirect: null, - // Enable browser language detection to automatically redirect user - // to their preferred language as they visit your app for the first time - // Set to false to disable + // Enable browser language detection to automatically redirect visitors to their + // preferred language as they visit your site for the first time. + // Note that for better SEO it's recommended to set 'onlyOnRoot' to true. + // Set to false to disable. detectBrowserLanguage: { // If enabled, a cookie is set once a user has been redirected to his // preferred language to prevent subsequent redirections @@ -86,7 +100,10 @@ Aquí están todas las opciones disponibles al configurar el módulo y sus valor // Set to always redirect to value stored in the cookie, not just once alwaysRedirect: false, // If no locale for the browsers locale is a match, use this one as a fallback - fallbackLocale: null + fallbackLocale: null, + // Set to true (recommended for improved SEO) to only attempt to detect browser locale + // on the root path ("/") of the site. Only effective when using strategy other than 'no_prefix'. + onlyOnRoot: false, }, // Set this to true if you're using different domains for each language diff --git a/src/helpers/constants.js b/src/helpers/constants.js index 8f3193738..9a2f327de 100644 --- a/src/helpers/constants.js +++ b/src/helpers/constants.js @@ -37,7 +37,8 @@ exports.DEFAULT_OPTIONS = { cookieKey: 'i18n_redirected', cookieSecure: false, alwaysRedirect: false, - fallbackLocale: '' + fallbackLocale: '', + onlyOnRoot: false }, differentDomains: false, seo: false, diff --git a/src/templates/plugin.main.js b/src/templates/plugin.main.js index 11424689f..d21009eca 100644 --- a/src/templates/plugin.main.js +++ b/src/templates/plugin.main.js @@ -38,6 +38,7 @@ import { Vue.use(VueI18n) +const { alwaysRedirect, onlyOnRoot, fallbackLocale } = detectBrowserLanguage const getLocaleFromRoute = createLocaleFromRouteGetter(localeCodes, { routesNameSeparator, defaultLocaleRouteNameSuffix }) /** @type {import('@nuxt/types').Plugin} */ @@ -79,10 +80,10 @@ export default async (context) => { if (!initialSetup) { app.i18n.beforeLanguageSwitch(oldLocale, newLocale) + } - if (useCookie) { - app.i18n.setLocaleCookie(newLocale) - } + if (useCookie) { + app.i18n.setLocaleCookie(newLocale) } // Lazy-loading enabled @@ -124,7 +125,11 @@ export default async (context) => { } if (getLocaleFromRoute(route) === locale) { - return '' + // If "onlyOnRoot" is set and strategy is "prefix_and_default", prefer unprefixed route for + // default locale. + if (!onlyOnRoot || locale !== defaultLocale || strategy !== STRATEGIES.PREFIX_AND_DEFAULT) { + return '' + } } // At this point we are left with route that either has no or different locale. @@ -166,7 +171,7 @@ export default async (context) => { app.i18n.__baseUrl = resolveBaseUrl(baseUrl, context) const finalLocale = - (detectBrowserLanguage && doDetectBrowserLanguage()) || + (detectBrowserLanguage && doDetectBrowserLanguage(route)) || getLocaleFromRoute(route) || app.i18n.locale || app.i18n.defaultLocale || '' await app.i18n.setLocale(finalLocale) @@ -174,13 +179,15 @@ export default async (context) => { return [null, null] } - const doDetectBrowserLanguage = () => { + const doDetectBrowserLanguage = route => { // Browser detection is ignored if it is a nuxt generate. if (process.static && process.server) { return false } - const { alwaysRedirect, fallbackLocale } = detectBrowserLanguage + if (onlyOnRoot && strategy !== STRATEGIES.NO_PREFIX && route.path !== '/') { + return false + } let matchedLocale @@ -199,8 +206,6 @@ export default async (context) => { if (finalLocale && (!useCookie || alwaysRedirect || !app.i18n.getLocaleCookie())) { if (finalLocale !== app.i18n.locale) { return finalLocale - } else if (useCookie && !app.i18n.getLocaleCookie()) { - app.i18n.setLocaleCookie(finalLocale) } } @@ -244,7 +249,7 @@ export default async (context) => { } } - let finalLocale = detectBrowserLanguage && doDetectBrowserLanguage() + let finalLocale = detectBrowserLanguage && doDetectBrowserLanguage(route) if (!finalLocale) { if (vuex && vuex.syncLocale && store && store.state[vuex.moduleName].locale !== '') { diff --git a/src/templates/plugin.routing.js b/src/templates/plugin.routing.js index 6a604c52b..6ea19df3b 100644 --- a/src/templates/plugin.routing.js +++ b/src/templates/plugin.routing.js @@ -148,11 +148,10 @@ function getRouteBaseName (givenRoute) { } function getLocaleRouteName (routeName, locale) { - const name = routeName + (strategy === STRATEGIES.NO_PREFIX ? '' : routesNameSeparator + locale) + let name = routeName + (strategy === STRATEGIES.NO_PREFIX ? '' : routesNameSeparator + locale) - // Match route without prefix for default locale if (locale === defaultLocale && strategy === STRATEGIES.PREFIX_AND_DEFAULT) { - return name + routesNameSeparator + defaultLocaleRouteNameSuffix + name += routesNameSeparator + defaultLocaleRouteNameSuffix } return name diff --git a/test/browser.test.js b/test/browser.test.js index 51b92e9c9..7a8005cb7 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -712,7 +712,52 @@ describe(`${browserString} (SPA with router in hash mode)`, () => { }) }) -describe(`${browserString} (alwaysRedirect)`, () => { +describe(`${browserString} (onlyOnRoot + alwaysRedirect + no_prefix)`, () => { + /** @type {Nuxt} */ + let nuxt + /** @type {import('playwright-chromium').ChromiumBrowser} */ + let browser + + beforeAll(async () => { + const overrides = { + i18n: { + defaultLocale: 'en', + strategy: 'no_prefix', + detectBrowserLanguage: { + useCookie: false, + alwaysRedirect: true, + onlyOnRoot: true + } + } + } + + nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt + browser = await createBrowser() + }) + + afterAll(async () => { + if (browser) { + await browser.close() + } + await nuxt.close() + }) + + test('onlyOnRoot does not affect locale detection on root path', async () => { + const page = await browser.newPage({ locale: 'fr' }) + await page.goto(url('/')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: fr') + }) + + test('onlyOnRoot does not affect locale detection on sub-path', async () => { + const page = await browser.newPage({ locale: 'fr' }) + await page.goto(url('/about')) + expect(await (await page.$('#current-page'))?.textContent()).toContain('page: À propos') + // Custom paths are not supported in "no_prefix" strategy. + // expect(await getRouteFullPath(page)).toBe('/a-propos') + }) +}) + +describe(`${browserString} (alwaysRedirect, prefix)`, () => { /** @type {Nuxt} */ let nuxt /** @type {import('playwright-chromium').ChromiumBrowser} */ @@ -750,6 +795,208 @@ describe(`${browserString} (alwaysRedirect)`, () => { }) }) +describe(`${browserString} (onlyOnRoot + prefix_except_default)`, () => { + /** @type {Nuxt} */ + let nuxt + /** @type {import('playwright-chromium').ChromiumBrowser} */ + let browser + + beforeAll(async () => { + const overrides = { + i18n: { + defaultLocale: 'en', + strategy: 'prefix_except_default', + detectBrowserLanguage: { + onlyOnRoot: true + } + } + } + + nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt + browser = await createBrowser() + }) + + afterAll(async () => { + if (browser) { + await browser.close() + } + await nuxt.close() + }) + + test('redirects to detected locale on unprefixed root path', async () => { + const page = await browser.newPage({ locale: 'fr' }) + await page.goto(url('/')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: fr') + }) + + test('does not detect locale and redirect on unprefixed non-root path', async () => { + const page = await browser.newPage({ locale: 'fr' }) + await page.goto(url('/simple')) + expect(await (await page.$('#container'))?.textContent()).toContain('Homepage') + expect(await getRouteFullPath(page)).toBe('/simple') + }) + + test('does not detect locale and redirect on prefixed, root path', async () => { + const page = await browser.newPage({ locale: 'en' }) + await page.goto(url('/fr/')) + expect(await (await page.$('#current-page'))?.textContent()).toContain('page: Accueil') + expect(await getRouteFullPath(page)).toBe('/fr/') + }) + + test('does not detect locale and redirect on prefixed, non-root path', async () => { + const page = await browser.newPage({ locale: 'en' }) + await page.goto(url('/fr/a-propos')) + expect(await (await page.$('#current-page'))?.textContent()).toContain('page: À propos') + expect(await getRouteFullPath(page)).toBe('/fr/a-propos') + }) + + test('does not redirect to locale stored in cookie on second navigation to root path', async () => { + const page = await browser.newPage({ locale: 'fr' }) + await page.goto(url('/')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: fr') + expect(await getRouteFullPath(page)).toBe('/fr') + const browserContext = page.context() + // Verify that cookie was set. + const cookies = await browserContext.cookies() + expect(cookies).toMatchObject([{ name: 'i18n_redirected', value: 'fr' }]) + // Navigate again to root, expecting to NOT be redirected again. + await page.goto(url('/')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: en') + expect(await getRouteFullPath(page)).toBe('/') + }) +}) + +describe(`${browserString} (onlyOnRoot + alwaysRedirect + prefix_except_default)`, () => { + /** @type {Nuxt} */ + let nuxt + /** @type {import('playwright-chromium').ChromiumBrowser} */ + let browser + + beforeAll(async () => { + const overrides = { + i18n: { + defaultLocale: 'en', + strategy: 'prefix_except_default', + detectBrowserLanguage: { + alwaysRedirect: true, + onlyOnRoot: true + } + } + } + + nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt + browser = await createBrowser() + }) + + afterAll(async () => { + if (browser) { + await browser.close() + } + await nuxt.close() + }) + + test('redirects to locale stored in cookie on second navigation to root path', async () => { + const page = await browser.newPage({ locale: 'fr' }) + await page.goto(url('/')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: fr') + expect(await getRouteFullPath(page)).toBe('/fr') + const browserContext = page.context() + // Verify that cookie was set. + const cookies = await browserContext.cookies() + expect(cookies).toMatchObject([{ name: 'i18n_redirected', value: 'fr' }]) + // Navigate again to root, expecting to be redirected again. + await page.goto(url('/')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: fr') + expect(await getRouteFullPath(page)).toBe('/fr') + }) +}) + +describe(`${browserString} (onlyOnRoot + prefix_and_default)`, () => { + /** @type {Nuxt} */ + let nuxt + /** @type {import('playwright-chromium').ChromiumBrowser} */ + let browser + + beforeAll(async () => { + const overrides = { + i18n: { + defaultLocale: 'en', + strategy: 'prefix_and_default', + detectBrowserLanguage: { + onlyOnRoot: true + } + } + } + + nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt + browser = await createBrowser() + }) + + afterAll(async () => { + if (browser) { + await browser.close() + } + await nuxt.close() + }) + + test('redirects from prefixed to unprefixed default locale', async () => { + const page = await browser.newPage() + await page.goto(url('/en')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: en') + expect(await getRouteFullPath(page)).toBe('/') + }) + + test('does not redirect from unprefixed default locale', async () => { + const page = await browser.newPage() + await page.goto(url('/')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: en') + expect(await getRouteFullPath(page)).toBe('/') + }) +}) + +describe(`${browserString} (onlyOnRoot + prefix)`, () => { + /** @type {Nuxt} */ + let nuxt + /** @type {import('playwright-chromium').ChromiumBrowser} */ + let browser + + beforeAll(async () => { + const overrides = { + i18n: { + defaultLocale: 'en', + strategy: 'prefix', + detectBrowserLanguage: { + onlyOnRoot: true + } + } + } + + nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt + browser = await createBrowser() + }) + + afterAll(async () => { + if (browser) { + await browser.close() + } + await nuxt.close() + }) + + test('does not redirect from one locale to another', async () => { + const page = await browser.newPage({ locale: 'fr' }) + await page.goto(url('/en')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: en') + expect(await getRouteFullPath(page)).toBe('/en') + }) + + test('redirects from root (404) path to default locale', async () => { + const page = await browser.newPage() + await page.goto(url('/')) + expect(await (await page.$('body'))?.textContent()).toContain('locale: en') + expect(await getRouteFullPath(page)).toBe('/en') + }) +}) + describe(`${browserString} (vuex disabled)`, () => { /** @type {Nuxt} */ let nuxt diff --git a/test/module.test.js b/test/module.test.js index dc3fb324f..d70bdf3b0 100644 --- a/test/module.test.js +++ b/test/module.test.js @@ -1135,6 +1135,34 @@ describe('with router base', () => { const newRoute = window.$nuxt.localePath('about') expect(newRoute).toBe('/about-us') }) + + test('detectBrowserLanguage redirects on base path', async () => { + const requestOptions = { + followRedirect: false, + resolveWithFullResponse: true, + simple: false, // Don't reject on non-2xx response + headers: { + 'Accept-Language': 'fr' + } + } + const response = await get('/app/', requestOptions) + expect(response.statusCode).toBe(302) + expect(response.headers.location).toBe('/app/fr') + }) + + test('detectBrowserLanguage redirects on non-base path', async () => { + const requestOptions = { + followRedirect: false, + resolveWithFullResponse: true, + simple: false, // Don't reject on non-2xx response + headers: { + 'Accept-Language': 'fr' + } + } + const response = await get('/app/simple', requestOptions) + expect(response.statusCode).toBe(302) + expect(response.headers.location).toBe('/app/fr/simple') + }) }) describe('baseUrl', () => { @@ -1433,6 +1461,17 @@ describe('no_prefix + detectBrowserLanguage + alwaysRedirect', () => { const dom = getDom(html) expect(dom.querySelector('#current-locale')?.textContent).toBe('locale: en') }) + + test('applies detected locale on non-root path', async () => { + const requestOptions = { + headers: { + 'Accept-Language': 'fr' + } + } + const html = await get('/about', requestOptions) + const dom = getDom(html) + expect(dom.querySelector('#current-page')?.textContent).toBe('page: À propos') + }) }) describe('prefix + detectBrowserLanguage + alwaysRedirect', () => { diff --git a/types/nuxt-i18n.d.ts b/types/nuxt-i18n.d.ts index a2fcca68b..73e74f397 100644 --- a/types/nuxt-i18n.d.ts +++ b/types/nuxt-i18n.d.ts @@ -34,6 +34,7 @@ declare namespace NuxtVueI18n { cookieKey?: string alwaysRedirect?: boolean fallbackLocale?: Locale | null + onlyOnRoot?: boolean } interface RootRedirectInterface {