Skip to content

Commit

Permalink
feat: add support for query params in canonical url (#1274)
Browse files Browse the repository at this point in the history
Co-authored-by: Rafał Chłodnicki <rchl2k@gmail.com>
  • Loading branch information
RobbieTheWagner and rchl authored Oct 18, 2021
1 parent 578acd8 commit d5dea9c
Show file tree
Hide file tree
Showing 9 changed files with 92 additions and 13 deletions.
6 changes: 4 additions & 2 deletions docs/content/en/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion docs/content/en/seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
36 changes: 31 additions & 5 deletions src/templates/head-meta.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -117,20 +118,45 @@ export function nuxtI18nHead ({ addDirAttribute = false, addSeoAttributes = fals
*
* @param {string} baseUrl
* @param {import('../../types/vue').NuxtI18nMeta['link']} link
* @param {NonNullable<import('../../types/vue').NuxtI18nHeadOptions['addSeoAttributes']>} 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
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion test/fixture/basic/nuxt.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
Expand Down
2 changes: 1 addition & 1 deletion test/fixture/basic/pages/about.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default {
},
/** @return {import('../../../../types/vue').NuxtI18nMeta} */
head () {
return this.$nuxtI18nHead()
return this.$nuxtI18nHead({ addSeoAttributes: { canonicalQueries: ['page'] } })
},
computed: {
/** @return {string} */
Expand Down
2 changes: 1 addition & 1 deletion test/fixture/basic/pages/locale.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<script>
export default {
head () {
return this.$nuxtI18nHead({ addDirAttribute: true, addSeoAttributes: true })
return this.$nuxtI18nHead({ addDirAttribute: true, addSeoAttributes: { canonicalQueries: ['foo'] } })
}
}
</script>
2 changes: 1 addition & 1 deletion test/fixture/no-lang-switcher/pages/seo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<script>
export default {
head () {
return this.$nuxtI18nHead({ addSeoAttributes: true })
return this.$nuxtI18nHead({ addSeoAttributes: { canonicalQueries: ['foo'] } })
}
}
</script>
32 changes: 32 additions & 0 deletions test/module.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
10 changes: 9 additions & 1 deletion types/vue.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<MetaInfo, 'htmlAttrs' | 'link' | 'meta'>>
Expand Down

0 comments on commit d5dea9c

Please sign in to comment.