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
}