Skip to content

Commit

Permalink
feat: add nuxt-i18n routes support with alternate links by hreflang
Browse files Browse the repository at this point in the history
fix #91
  • Loading branch information
NicoPennec committed May 11, 2020
1 parent 27b2964 commit 50a51d6
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 18 deletions.
45 changes: 45 additions & 0 deletions README.md
Expand Up @@ -15,6 +15,7 @@
- Module based on the awesome **[sitemap.js](https://github.com/ekalinin/sitemap.js) package** ❤️
- Create **sitemap** or **sitemap index**
- Automatically add the static routes to each sitemap
- Support **i18n** routes from **nuxt-i18n** (latest version)
- Works with **all modes** (SSR, SPA, generate)
- For **Nuxt 2.x** and higher

Expand Down Expand Up @@ -343,6 +344,50 @@ Add a trailing slash to each route URL (eg. `/page/1` => `/page/1/`)

> **notice:** To avoid [duplicate content](https://support.google.com/webmasters/answer/66359) detection from crawlers, you have to configure an HTTP 301 redirect between the 2 URLs (see [redirect-module](https://github.com/nuxt-community/redirect-module) or [nuxt-trailingslash-module](https://github.com/WilliamDASILVA/nuxt-trailingslash-module)).
### `i18n` (optional) - string | object

- Default: `undefined`

Configure the support of localized routes from **[nuxt-i18n](https://www.npmjs.com/package/nuxt-i18n)** module.

If the `i18n` option is configured, the sitemap module will automatically add the default locale URL of each page in a `<loc>` element, with child `<xhtml:link>` entries listing every language/locale variant of the page including itself (see [Google sitemap guidelines](https://support.google.com/webmasters/answer/189077)).

Examples:

```js
// nuxt.config.js

{
modules: [
'nuxt-i18n',
'@nuxtjs/sitemap'
],
i18n: {
locales: ['en', 'es', 'fr'],
defaultLocale: 'en'
},
sitemap: {
hostname: 'https://example.com',
// shortcut notation (basic)
i18n: 'en',
// nuxt-i18n notation (advanced)
i18n: {
defaultLocale: 'en',
routesNameSeparator: '___'
}
}
}
```

```xml
<url>
<loc>https://example.com/</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/"/>
<xhtml:link rel="alternate" hreflang="es" href="https://example.com/es/"/>
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/"/>
</url>
```

### `defaults` (optional) - object

- Default: `{}`
Expand Down
48 changes: 47 additions & 1 deletion lib/builder.js
Expand Up @@ -44,7 +44,53 @@ function createSitemap(options, routes, base = null, req = null) {
})
}

// Enable filter function for each declared route
// Group each route with its alternative languages
if (options.i18n) {
const { defaultLocale, routesNameSeparator } = options.i18n

// Set alternate routes for each page
const i18nRoutes = routes.reduce((i18nRoutes, route, index) => {
if (!route.name) {
// Route without alternate link
i18nRoutes[`#${index}`] = route
return i18nRoutes
}

let [page, lang, isDefault] = route.name.split(routesNameSeparator) // eslint-disable-line prefer-const

// Get i18n route, or init it
const i18nRoute = i18nRoutes[page] || { ...route }

if (lang) {
// Set main link
if (isDefault) {
lang = 'x-default'
}
if (lang === defaultLocale) {
i18nRoute.url = route.url
}

// Set alternate links
if (!i18nRoute.links) {
i18nRoute.links = []
}
i18nRoute.links.push({
lang,
url: route.url,
})
} else {
// No alternate link found
i18nRoute.url = route.url
}

i18nRoutes[page] = i18nRoute
return i18nRoutes
}, {})

routes = Object.values(i18nRoutes)
}

// Enable the custom filter function for each declared route
if (typeof options.filter === 'function') {
routes = options.filter({
options: { ...sitemapConfig },
Expand Down
28 changes: 28 additions & 0 deletions lib/options.js
@@ -1,3 +1,5 @@
const MODULE_NAME = require('../package.json').name

const logger = require('./logger')

const DEFAULT_NUXT_PUBLIC_PATH = '/_nuxt/'
Expand Down Expand Up @@ -27,6 +29,7 @@ function setDefaultSitemapOptions(options, nuxtInstance, isLinkedToSitemapIndex
xslUrl: undefined,
trailingSlash: false,
lastmod: undefined,
i18n: undefined,
defaults: {},
}

Expand All @@ -35,6 +38,31 @@ function setDefaultSitemapOptions(options, nuxtInstance, isLinkedToSitemapIndex
...options,
}

if (sitemapOptions.i18n) {
// Check modules config
const modules = Object.keys(nuxtInstance.requiredModules)
/* istanbul ignore if */
if (modules.indexOf('nuxt-i18n') > modules.indexOf(MODULE_NAME)) {
logger.warn(
`To enable the "i18n" option, the "${MODULE_NAME}" must be declared after the "nuxt-i18n" module in your config`
)
}

// Shortcut notation
if (typeof sitemapOptions.i18n === 'string') {
sitemapOptions.i18n = {
defaultLocale: sitemapOptions.i18n,
}
}

// Set default i18n options
sitemapOptions.i18n = {
defaultLocale: '',
routesNameSeparator: '___',
...sitemapOptions.i18n,
}
}

/* istanbul ignore if */
if (sitemapOptions.generate) {
logger.warn("The `generate` option isn't needed anymore in your config. Please remove it!")
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -72,6 +72,7 @@
"jest": "latest",
"lint-staged": "latest",
"nuxt": "latest",
"nuxt-i18n": "latest",
"prettier": "latest",
"request-promise-native": "latest",
"standard-version": "latest"
Expand Down
108 changes: 106 additions & 2 deletions test/module.test.js
Expand Up @@ -7,6 +7,7 @@ const request = require('request-promise-native')

const config = require('./fixture/nuxt.config')
config.dev = false
config.modules = [require('..')]
config.sitemap = {}

const url = (path) => `http://localhost:3000${path}`
Expand Down Expand Up @@ -329,8 +330,6 @@ describe('sitemap - advanced configuration', () => {
})

const xml = await get('/sitemap.xml')

// trailing slash
expect(xml).not.toContain('<loc>https://example.com/sub</loc>')
expect(xml).not.toContain('<loc>https://example.com/sub/sub</loc>')
expect(xml).not.toContain('<loc>https://example.com/test</loc>')
Expand All @@ -340,6 +339,111 @@ describe('sitemap - advanced configuration', () => {
})
})

describe('i18n options', () => {
const modules = [require('nuxt-i18n'), require('..')]

const nuxtI18nConfig = {
locales: ['en', 'fr'],
defaultLocale: 'en',
}

const sitemapConfig = {
hostname: 'https://example.com',
trailingSlash: true,
i18n: 'en',
routes: ['foo', { url: 'bar' }],
}

test('strategy "no_prefix"', async () => {
nuxt = await startServer({
...config,
modules,
i18n: {
...nuxtI18nConfig,
strategy: 'no_prefix',
},
sitemap: sitemapConfig,
})

const xml = await get('/sitemap.xml')
expect(xml).toContain('<loc>https://example.com/</loc>')
expect(xml).not.toContain('<loc>https://example.com/en/</loc>')
expect(xml).not.toContain('<loc>https://example.com/fr/</loc>')
expect(xml).not.toContain('<xhtml:link rel="alternate" hreflang="en" href="https://example.com/"/>')
expect(xml).not.toContain('<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/"/>')
expect(xml).not.toContain('<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/"/>')
expect(xml).not.toContain('<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/"/>')
})

test('strategy "prefix"', async () => {
nuxt = await startServer({
...config,
modules,
i18n: {
...nuxtI18nConfig,
strategy: 'prefix',
},
sitemap: sitemapConfig,
})

const xml = await get('/sitemap.xml')
expect(xml).not.toContain('<loc>https://example.com/</loc>')
expect(xml).toContain('<loc>https://example.com/en/</loc>')
expect(xml).not.toContain('<loc>https://example.com/fr/</loc>')
expect(xml).not.toContain('<xhtml:link rel="alternate" hreflang="en" href="https://example.com/"/>')
expect(xml).toContain('<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/"/>')
expect(xml).toContain('<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/"/>')
expect(xml).not.toContain('<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/"/>')
})

test('strategy "prefix_except_default"', async () => {
nuxt = await startServer({
...config,
modules,
i18n: {
...nuxtI18nConfig,
strategy: 'prefix_except_default',
},
sitemap: sitemapConfig,
})

const xml = await get('/sitemap.xml')
expect(xml).toContain('<loc>https://example.com/</loc>')
expect(xml).not.toContain('<loc>https://example.com/en/</loc>')
expect(xml).not.toContain('<loc>https://example.com/fr/</loc>')
expect(xml).toContain('<xhtml:link rel="alternate" hreflang="en" href="https://example.com/"/>')
expect(xml).not.toContain('<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/"/>')
expect(xml).toContain('<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/"/>')
expect(xml).not.toContain('<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/"/>')
})

test('strategy "prefix_and_default"', async () => {
nuxt = await startServer({
...config,
modules,
i18n: {
...nuxtI18nConfig,
strategy: 'prefix_and_default',
},
sitemap: {
...sitemapConfig,
i18n: {
defaultLocale: 'x-default',
},
},
})

const xml = await get('/sitemap.xml')
expect(xml).toContain('<loc>https://example.com/</loc>')
expect(xml).not.toContain('<loc>https://example.com/en/</loc>')
expect(xml).not.toContain('<loc>https://example.com/fr/</loc>')
expect(xml).not.toContain('<xhtml:link rel="alternate" hreflang="en" href="https://example.com/"/>')
expect(xml).toContain('<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/"/>')
expect(xml).toContain('<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/"/>')
expect(xml).toContain('<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/"/>')
})
})

describe('external options', () => {
test('default hostname from build.publicPath', async () => {
nuxt = await startServer({
Expand Down

0 comments on commit 50a51d6

Please sign in to comment.