Skip to content

Commit

Permalink
feat(seo): additional catchall hreflang tags (#597)
Browse files Browse the repository at this point in the history
Implements additional hreflang tags that are not specific to a country, if not provided
explicitly.

Resolves #522
  • Loading branch information
TheAlexLichter authored Feb 24, 2020
1 parent deeb607 commit ebd2213
Show file tree
Hide file tree
Showing 5 changed files with 318 additions and 67 deletions.
94 changes: 86 additions & 8 deletions docs/seo.md
Original file line number Diff line number Diff line change
@@ -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 `<html>` tag.
* Generate `<link rel="alternate" hreflang="x">` 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 `<html>` tag.

With `seo` enabled, nuxt-i18n will set the correct `lang` attribute, equivalent to the current locale's ISO code, in the `<html>` tag.

### Automatic hreflang generation

nuxt-i18n will generate `<link rel="alternate" hreflang="x">` 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
Expand All @@ -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
Expand All @@ -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
Expand Down
162 changes: 109 additions & 53 deletions src/templates/seo-head.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,71 +21,127 @@ 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,
link,
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]
}
2 changes: 1 addition & 1 deletion test/fixture/base.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ module.exports = {
locales: [
{
code: 'en',
iso: 'en-US',
iso: 'en',
name: 'English'
},
{
Expand Down
Loading

0 comments on commit ebd2213

Please sign in to comment.