diff --git a/docs/seo.md b/docs/seo.md index f30cabc67..6c5ed9782 100644 --- a/docs/seo.md +++ b/docs/seo.md @@ -1,15 +1,92 @@ # SEO -With `seo` option enabled, **nuxt-i18n** attempts to add some metadata to improve your pages SEO. Here's what it does: -* Add a _lang_ attribute containing current locale's ISO code to the `` tag. -* Generate `` tags for every language configured in `nuxt.config.js`. For each language, the ISO code is used as `hreflang` attribute's value. [More on hreflang](https://support.google.com/webmasters/answer/189077) -* Generate `og:locale` and `og:locale:alternate` meta tags as defined in the [Open Graph protocol](http://ogp.me/#optional) -* When using `prefix_and_default` strategy, generate `rel="canonical"` link on the default language routes containing the +## Benefits + +When the `seo` option is enabled, **nuxt-i18n** attempts to add some metadata to improve your pages SEO. Here's what it does. + +### `lang` attribute for `` tag. + +With `seo` enabled, nuxt-i18n will set the correct `lang` attribute, equivalent to the current locale's ISO code, in the `` tag. + +### Automatic hreflang generation + +nuxt-i18n will generate `` tags for every language configured in `nuxt.config.js`. +The language's ISO codes are used as `hreflang` values. + +Since version [v6.6.0](https://github.com/nuxt-community/nuxt-i18n/releases/tag/v6.6.0), a catchall locale hreflang link +is provided for each language group (e.g. `en-*`) as well. +By default, it is the first language provided but another language can be selected by setting `isCatchallLocale` to `true` +on that specific language object in your `nuxt.config.js`. [More on hreflang](https://support.google.com/webmasters/answer/189077) + +An example without selected catchall locale: +```js +// nuxt.config.js + +['nuxt-i18n', { + locales: [ + { + code: 'en', + iso: 'en-US' // Will be used as catchall locale by default + }, + { + code: 'gb', + iso: 'en-GB' + } + ] +}] +``` + +Here is how you'd use `isCatchallLocale` to selected another language: +```js +// nuxt.config.js + +['nuxt-i18n', { + locales: [ + { + code: 'en', + iso: 'en-US' + }, + { + code: 'gb', + iso: 'en-GB', + isCatchallLocale: true // This one will be used as catchall locale + } + ] +}] +``` + +In case you already have an `en` language iso set, it'll be used as the catchall without doing anything + +```js +// nuxt.config.js + +['nuxt-i18n', { + locales: [ + { + code: 'gb', + iso: 'en-GB' + }, + { + code: 'en', + iso: 'en' // will be used as catchall locale + } + ] +}] +``` + +### OpenGraph Locale tag generation + +nuxt-i18n will also generate `og:locale` and `og:locale:alternate` meta tags as defined in the [Open Graph protocol](http://ogp.me/#optional). + +### Canonical links for `prefix_and_default` + +When using the `prefix_and_default` strategy, `rel="canonical"` links on the default language routes will be generated and contain the prefix to avoid duplicate indexation. [More on canonical](https://support.google.com/webmasters/answer/182192#dup-content) +## Requirements -For this feature to work, you must configure `locales` option as an array of objects, where each object has an `iso` option set to the language ISO code: +To leverage the SEO benefits of nuxt-i18n, you must configure the `locales` option as an array of objects, where each object has an `iso` option set to the language's ISO code: ```js // nuxt.config.js @@ -32,7 +109,7 @@ For this feature to work, you must configure `locales` option as an array of obj }] ``` -You should also set the `baseUrl` option to your production domain in order to make alternate URLs fully-qualified: +You must also set the `baseUrl` option to your production domain in order to make alternate URLs fully-qualified: ```js // nuxt.config.js @@ -43,7 +120,8 @@ You should also set the `baseUrl` option to your production domain in order to m ``` -To enable this feature everywhere in your app, set `seo` option to `true`: +To enable this feature everywhere in your app, set `seo` option to `true`. +**This comes with a performance drawback though**. More information below. ```js // nuxt.config.js diff --git a/src/templates/seo-head.js b/src/templates/seo-head.js index 63a063844..8d41d8bdf 100644 --- a/src/templates/seo-head.js +++ b/src/templates/seo-head.js @@ -21,67 +21,25 @@ export const nuxtI18nSeo = function () { return {} } // Prepare html lang attribute - const currentLocaleData = this.$i18n.locales.find(l => l[LOCALE_CODE_KEY] === this.$i18n.locale) + const currentLocale = this.$i18n.locales.find(l => codeFromLocale(l) === this.$i18n.locale) + const currentLocaleIso = isoFromLocale(currentLocale) + const htmlAttrs = {} - if (currentLocaleData && currentLocaleData[LOCALE_ISO_KEY]) { - htmlAttrs.lang = currentLocaleData[LOCALE_ISO_KEY] + + if (currentLocale && currentLocaleIso) { + htmlAttrs.lang = currentLocaleIso // TODO: simple lang or "specific" lang with territory? } const link = [] - // hreflang tags - if (strategy !== STRATEGIES.NO_PREFIX) { - link.push(...this.$i18n.locales - .map(locale => { - if (locale[LOCALE_ISO_KEY]) { - return { - hid: 'alternate-hreflang-' + locale[LOCALE_ISO_KEY], - rel: 'alternate', - href: baseUrl + this.switchLocalePath(locale.code), - hreflang: locale[LOCALE_ISO_KEY] - } - } else { - // eslint-disable-next-line no-console - console.warn(`[${MODULE_NAME}] Locale ISO code is required to generate alternate link`) - return null - } - }) - .filter(item => !!item)) - } - // canonical links - if (strategy === STRATEGIES.PREFIX_AND_DEFAULT) { - const canonicalPath = this.switchLocalePath(currentLocaleData[LOCALE_CODE_KEY]) - if (canonicalPath && canonicalPath !== this.$route.path) { - // Current page is not the canonical one -- add a canonical link - link.push({ - hid: 'canonical-lang-' + currentLocaleData[LOCALE_CODE_KEY], - rel: 'canonical', - href: baseUrl + canonicalPath - }) - } - } + addHreflangLinks.bind(this)(currentLocale, this.$i18n.locales, link) + + addCanonicalLinks.bind(this)(currentLocale, link) // og:locale meta const meta = [] - // og:locale - current - if (currentLocaleData && currentLocaleData[LOCALE_ISO_KEY]) { - meta.push({ - hid: 'og:locale', - property: 'og:locale', - // Replace dash with underscore as defined in spec: language_TERRITORY - content: currentLocaleData[LOCALE_ISO_KEY].replace(/-/g, '_') - }) - } - // og:locale - alternate - meta.push( - ...this.$i18n.locales - .filter(l => l[LOCALE_ISO_KEY] && l[LOCALE_ISO_KEY] !== currentLocaleData[LOCALE_ISO_KEY]) - .map(locale => ({ - hid: 'og:locale:alternate-' + locale[LOCALE_ISO_KEY], - property: 'og:locale:alternate', - content: locale[LOCALE_ISO_KEY].replace(/-/g, '_') - })) - ) + addCurrentOgLocale.bind(this)(currentLocale, meta) + addAlternateOgLocales.bind(this)(this.$i18n.locales, currentLocale, meta) return { htmlAttrs, @@ -89,3 +47,101 @@ export const nuxtI18nSeo = function () { meta } } + +function addHreflangLinks (currentLocale, locales, link) { + if (strategy === STRATEGIES.NO_PREFIX) { + return + } + + const localeMap = new Map() + + for (const locale of locales) { + const localeIso = isoFromLocale(locale) + + if (!localeIso) { + // eslint-disable-next-line no-console + console.warn(`[${MODULE_NAME}] Locale ISO code is required to generate alternate link`) + continue + } + + const [language, region] = localeIso.split('-') + + if (language && region && (locale.isCatchallLocale || !localeMap.has(language))) { + localeMap.set(language, locale) + } + + localeMap.set(localeIso, locale) + } + + for (const [iso, locale] of localeMap.entries()) { + link.push({ + hid: `alternate-hreflang-${iso}`, + rel: 'alternate', + href: baseUrl + this.switchLocalePath(locale.code), + hreflang: iso + }) + } +} + +function addCanonicalLinks (currentLocale, link) { + if (strategy !== STRATEGIES.PREFIX_AND_DEFAULT) { + return + } + + const currentLocaleCode = codeFromLocale(currentLocale) + + const canonicalPath = this.switchLocalePath(currentLocaleCode) + + const canonicalPathIsDifferentFromCurrent = canonicalPath !== this.$route.path + const shouldAddCanonical = canonicalPath && canonicalPathIsDifferentFromCurrent + if (!shouldAddCanonical) { + return + } + + link.push({ + hid: `canonical-lang-${currentLocaleCode}`, + rel: 'canonical', + href: baseUrl + canonicalPath + }) +} + +function addCurrentOgLocale (currentLocale, meta) { + const hasCurrentLocaleAndIso = currentLocale && isoFromLocale(currentLocale) + + if (!hasCurrentLocaleAndIso) { + return + } + + meta.push({ + hid: 'og:locale', + property: 'og:locale', + // Replace dash with underscore as defined in spec: language_TERRITORY + content: underscoreIsoFromLocale(currentLocale) + }) +} + +function addAlternateOgLocales (locales, currentLocale, meta) { + const localesWithoutCurrent = l => isoFromLocale(l) && isoFromLocale(l) !== isoFromLocale(currentLocale) + + const alternateLocales = locales + .filter(localesWithoutCurrent) + .map(locale => ({ + hid: `og:locale:alternate-${isoFromLocale(locale)}`, + property: 'og:locale:alternate', + content: underscoreIsoFromLocale(locale) + })) + + meta.push(...alternateLocales) +} + +function isoFromLocale (locale) { + return locale[LOCALE_ISO_KEY] +} + +function underscoreIsoFromLocale (locale) { + return isoFromLocale(locale).replace(/-/g, '_') +} + +function codeFromLocale (locale) { + return locale[LOCALE_CODE_KEY] +} diff --git a/test/fixture/base.config.js b/test/fixture/base.config.js index c4af015c8..a36cb2716 100644 --- a/test/fixture/base.config.js +++ b/test/fixture/base.config.js @@ -31,7 +31,7 @@ module.exports = { locales: [ { code: 'en', - iso: 'en-US', + iso: 'en', name: 'English' }, { diff --git a/test/module.test.js b/test/module.test.js index f5d484256..84fdef605 100644 --- a/test/module.test.js +++ b/test/module.test.js @@ -27,11 +27,13 @@ describe('basic', () => { test('sets SEO metadata properly', async () => { const html = await get('/') const dom = getDom(html) - expect(getSeoTags(dom)).toEqual(expect.arrayContaining([ + const seoTags = getSeoTags(dom) + + const expectedSeoTags = [ { tagName: 'meta', property: 'og:locale', - content: 'en_US' + content: 'en' }, { tagName: 'meta', @@ -42,7 +44,13 @@ describe('basic', () => { tagName: 'link', rel: 'alternate', href: 'nuxt-app.localhost/', - hreflang: 'en-US' + hreflang: 'en' + }, + { + tagName: 'link', + rel: 'alternate', + href: 'nuxt-app.localhost/fr', + hreflang: 'fr' }, { tagName: 'link', @@ -50,7 +58,9 @@ describe('basic', () => { href: 'nuxt-app.localhost/fr', hreflang: 'fr-FR' } - ])) + ] + + expect(seoTags).toEqual(expectedSeoTags) }) test('/ contains EN text, link to /fr/ & link /about-us', async () => { @@ -320,6 +330,112 @@ describe('basic', () => { }) }) +describe('hreflang', () => { + let nuxt + + beforeAll(async () => { + const testConfig = loadConfig(__dirname, 'basic') + + // Override those after merging to overwrite original values. + testConfig.i18n.locales = [ + { + code: 'en', + iso: 'en', + name: 'English' + }, + { + code: 'fr', + iso: 'fr-FR', + name: 'Français' + }, + { + code: 'es', + iso: 'es-ES', + name: 'Spanish (Spain)' + }, + { + code: 'esVe', + iso: 'es-VE', + name: 'Spanish (Venezuela)', + isCatchallLocale: true + } + ] + + nuxt = (await setup(testConfig)).nuxt + }) + + test('sets SEO metadata properly', async () => { + const html = await get('/') + const dom = getDom(html) + const seoTags = getSeoTags(dom) + + const expectedSeoTags = [ + { + content: 'en', + property: 'og:locale', + tagName: 'meta' + }, + { + content: 'fr_FR', + property: 'og:locale:alternate', + tagName: 'meta' + }, + { + content: 'es_ES', + property: 'og:locale:alternate', + tagName: 'meta' + }, + { + content: 'es_VE', + property: 'og:locale:alternate', + tagName: 'meta' + }, + { + href: 'nuxt-app.localhost/', + hreflang: 'en', + rel: 'alternate', + tagName: 'link' + }, + { + href: 'nuxt-app.localhost/fr', + hreflang: 'fr', + rel: 'alternate', + tagName: 'link' + }, + { + href: 'nuxt-app.localhost/fr', + hreflang: 'fr-FR', + rel: 'alternate', + tagName: 'link' + }, + { + href: 'nuxt-app.localhost/esVe', + hreflang: 'es', + rel: 'alternate', + tagName: 'link' + }, + { + href: 'nuxt-app.localhost/es', + hreflang: 'es-ES', + rel: 'alternate', + tagName: 'link' + }, + { + href: 'nuxt-app.localhost/esVe', + hreflang: 'es-VE', + rel: 'alternate', + tagName: 'link' + } + ] + + expect(seoTags).toEqual(expectedSeoTags) + }) + + afterAll(async () => { + await nuxt.close() + }) +}) + describe('lazy loading', () => { let nuxt @@ -446,7 +562,7 @@ describe('no_prefix strategy', () => { { tagName: 'meta', property: 'og:locale', - content: 'en_US' + content: 'en' }, { tagName: 'meta', diff --git a/types/nuxt-i18n.d.ts b/types/nuxt-i18n.d.ts index 7b5b6da46..486d334d5 100644 --- a/types/nuxt-i18n.d.ts +++ b/types/nuxt-i18n.d.ts @@ -20,6 +20,7 @@ declare namespace NuxtVueI18n { iso?: string // can be undefined: https://goo.gl/ryc5pF file?: string + isCatchallLocale?: boolean // Allow custom properties, e.g. "name": https://goo.gl/wrcb2G [key: string]: any }