diff --git a/docs/content/en/api.md b/docs/content/en/api.md index 937fb49e6..c385ed2c9 100644 --- a/docs/content/en/api.md +++ b/docs/content/en/api.md @@ -73,8 +73,10 @@ All [Vue I18n properties and methods](http://kazupon.github.io/vue-i18n/api/#vue - **Returns**: [`MetaInfo`](https://github.com/nuxt/vue-meta/blob/74182e388ad1b1977cb7217b0ade729321761403/types/vue-meta.d.ts#L173) The `options` object accepts these optional properties: - - `addDirAttribute` - Adds a `dir` attribute to the HTML element. Default: `false` - - `addSeoAttributes` - Adds various SEO attributes. Default: `false` + - `addDirAttribute` (type: `boolean`) - Adds a `dir` attribute to the HTML element. Default: `false` + - `addSeoAttributes` (type: `boolean | SeoAttributesOptions`) - Adds various SEO attributes. Default: `false` + + See also [SEO](../seo). ## Extension of VueI18n diff --git a/docs/content/en/seo.md b/docs/content/en/seo.md index 4a71395a3..9c29b2925 100644 --- a/docs/content/en/seo.md +++ b/docs/content/en/seo.md @@ -185,6 +185,17 @@ export default { Generates `rel="canonical"` link on all pages to specify the "main" version of the page that should be indexed by search engines. This is beneficial in various situations: - When using the `prefix_and_default` strategy there are technically two sets of pages generated for the default locale -- one prefixed and one unprefixed. The canonical link will be set to the unprefixed version of the page to avoid duplicate indexation. - - When the page contains the query parameters, the canonical link will **not include** query params. This is typically the right thing to do as various query params can be inserted by trackers and should not be part of the canonical link. Note that there is currently no way to override that in case that including a specific query param would be desired. + - When the page contains query parameters, the canonical link will **not include** the query params by default. This is typically the right thing to do as various query params can be inserted by trackers and should not be part of the canonical link. This can be overriden by using the `canonicalQueries` option. For example: + + ```js + export default { + head() { + return this.$nuxtI18nHead({ + addSeoAttributes: { + canonicalQueries: ['foo'] + } + }) + } + ``` [More on canonical](https://support.google.com/webmasters/answer/182192#dup-content) diff --git a/src/templates/head-meta.js b/src/templates/head-meta.js index d6c24517e..4d8902730 100644 --- a/src/templates/head-meta.js +++ b/src/templates/head-meta.js @@ -4,6 +4,7 @@ import { formatMessage } from './utils-common' /** * @this {import('vue/types/vue').Vue} + * @param {import('../../types/vue').NuxtI18nHeadOptions} options * @return {import('vue-meta').MetaInfo} */ export function nuxtI18nHead ({ addDirAttribute = false, addSeoAttributes = false } = {}) { @@ -47,7 +48,7 @@ export function nuxtI18nHead ({ addDirAttribute = false, addSeoAttributes = fals const locales = /** @type {import('../../types').LocaleObject[]} */(this.$i18n.locales) addHreflangLinks.bind(this)(locales, this.$i18n.__baseUrl, metaObject.link) - addCanonicalLinks.bind(this)(this.$i18n.__baseUrl, metaObject.link) + addCanonicalLinks.bind(this)(this.$i18n.__baseUrl, metaObject.link, addSeoAttributes) addCurrentOgLocale.bind(this)(currentLocale, currentLocaleIso, metaObject.meta) addAlternateOgLocales.bind(this)(locales, currentLocaleIso, metaObject.meta) } @@ -117,20 +118,45 @@ export function nuxtI18nHead ({ addDirAttribute = false, addSeoAttributes = fals * * @param {string} baseUrl * @param {import('../../types/vue').NuxtI18nMeta['link']} link + * @param {NonNullable} seoAttributesOptions */ - function addCanonicalLinks (baseUrl, link) { + function addCanonicalLinks (baseUrl, link, seoAttributesOptions) { const currentRoute = this.localeRoute({ ...this.$route, name: this.getRouteBaseName() }) - const canonicalPath = currentRoute ? currentRoute.path : null + if (currentRoute) { + let href = toAbsoluteUrl(currentRoute.path, baseUrl) + + const canonicalQueries = (typeof (seoAttributesOptions) !== 'boolean' && seoAttributesOptions.canonicalQueries) || [] + + if (canonicalQueries.length) { + const currentRouteQueryParams = currentRoute.query + const params = new URLSearchParams() + for (const queryParamName of canonicalQueries) { + if (queryParamName in currentRouteQueryParams) { + const queryParamValue = currentRouteQueryParams[queryParamName] + + if (Array.isArray(queryParamValue)) { + queryParamValue.forEach(v => params.append(queryParamName, v || '')) + } else { + params.append(queryParamName, queryParamValue || '') + } + } + } + + const queryString = params.toString() + + if (queryString) { + href = `${href}?${queryString}` + } + } - if (canonicalPath) { link.push({ hid: 'i18n-can', rel: 'canonical', - href: toAbsoluteUrl(canonicalPath, baseUrl) + href }) } } diff --git a/test/fixture/basic/nuxt.config.js b/test/fixture/basic/nuxt.config.js index 05f9d9973..ff3cc04b1 100644 --- a/test/fixture/basic/nuxt.config.js +++ b/test/fixture/basic/nuxt.config.js @@ -11,7 +11,7 @@ const config = { // SPARenderer calls this function without having `this` as the root Vue Component // so null-check before calling. if (this.$nuxtI18nHead) { - return this.$nuxtI18nHead({ addSeoAttributes: true }) + return this.$nuxtI18nHead({ addSeoAttributes: { canonicalQueries: ['foo'] } }) } return {} } diff --git a/test/fixture/basic/pages/about.vue b/test/fixture/basic/pages/about.vue index ab8274181..b8870c69a 100644 --- a/test/fixture/basic/pages/about.vue +++ b/test/fixture/basic/pages/about.vue @@ -17,7 +17,7 @@ export default { }, /** @return {import('../../../../types/vue').NuxtI18nMeta} */ head () { - return this.$nuxtI18nHead() + return this.$nuxtI18nHead({ addSeoAttributes: { canonicalQueries: ['page'] } }) }, computed: { /** @return {string} */ diff --git a/test/fixture/basic/pages/locale.vue b/test/fixture/basic/pages/locale.vue index 68d2f9d05..6b3c750a7 100644 --- a/test/fixture/basic/pages/locale.vue +++ b/test/fixture/basic/pages/locale.vue @@ -5,7 +5,7 @@ diff --git a/test/fixture/no-lang-switcher/pages/seo.vue b/test/fixture/no-lang-switcher/pages/seo.vue index 09b61c581..7231c6808 100644 --- a/test/fixture/no-lang-switcher/pages/seo.vue +++ b/test/fixture/no-lang-switcher/pages/seo.vue @@ -9,7 +9,7 @@ diff --git a/test/module.test.js b/test/module.test.js index b41f1dc06..f4de000e2 100644 --- a/test/module.test.js +++ b/test/module.test.js @@ -1184,6 +1184,38 @@ describe('prefix_and_default strategy', () => { expect(links.length).toBe(1) expect(links[0].getAttribute('href')).toBe('nuxt-app.localhost/') }) + + test('canonical SEO link includes query params in canonicalQueries', async () => { + const html = await get('/?foo="bar"') + const dom = getDom(html) + const links = dom.querySelectorAll('head link[rel="canonical"]') + expect(links.length).toBe(1) + expect(links[0].getAttribute('href')).toBe('nuxt-app.localhost/?foo=%22bar%22') + }) + + test('canonical SEO link includes query params without values in canonicalQueries', async () => { + const html = await get('/?foo') + const dom = getDom(html) + const links = dom.querySelectorAll('head link[rel="canonical"]') + expect(links.length).toBe(1) + expect(links[0].getAttribute('href')).toBe('nuxt-app.localhost/?foo=') + }) + + test('canonical SEO link does not include query params not in canonicalQueries', async () => { + const html = await get('/?bar="baz"') + const dom = getDom(html) + const links = dom.querySelectorAll('head link[rel="canonical"]') + expect(links.length).toBe(1) + expect(links[0].getAttribute('href')).toBe('nuxt-app.localhost/') + }) + + test('canonical SEO link includes query params in canonicalQueries on page level', async () => { + const html = await get('/about-us?foo=baz&page=1') + const dom = getDom(html) + const links = dom.querySelectorAll('head link[rel="canonical"]') + expect(links.length).toBe(1) + expect(links[0].getAttribute('href')).toBe('nuxt-app.localhost/about-us?page=1') + }) }) describe('no_prefix strategy', () => { diff --git a/types/vue.d.ts b/types/vue.d.ts index 1732355b7..96df97a77 100644 --- a/types/vue.d.ts +++ b/types/vue.d.ts @@ -23,7 +23,15 @@ interface NuxtI18nHeadOptions { * Adds various SEO attributes. * @default false */ - addSeoAttributes?: boolean + addSeoAttributes?: boolean | SeoAttributesOptions +} + +interface SeoAttributesOptions { + /** + * An array of strings corresponding to query params you would like to include in your canonical URL. + * @default [] + */ + canonicalQueries?: string[] } type NuxtI18nMeta = Required>