Skip to content

Commit 16d8e18

Browse files
divineBobbieGoede
andauthoredFeb 6, 2025··
feat: opt-in alternate link consistency (#3320)
Co-authored-by: Bobbie Goede <bobbiegoede@gmail.com>
1 parent 9f47117 commit 16d8e18

File tree

18 files changed

+186
-47
lines changed

18 files changed

+186
-47
lines changed
 

‎docs/content/docs/4.api/0.options.md

+6
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,12 @@ This feature relies on [Nuxt's `experimental.typedRoutes`](https://nuxt.com/docs
511511
Changing this will also change the paths in `locales` returned by `useI18n()`{lang="ts"}.
512512
::
513513

514+
### `alternateLinkCanonicalQueries`
515+
516+
- type: `boolean`{lang="ts-type"}
517+
- default: `false`{lang="ts"}
518+
- Whether to remove non-canonical query parameters from alternate link meta tags
519+
514520
## customBlocks
515521

516522
Configure the `i18n` custom blocks of SFC.

‎specs/basic_usage.spec.ts

+31-4
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ describe('basic usage', async () => {
2828
i18n: {
2929
baseUrl: 'http://localhost:3000',
3030
skipSettingLocaleOnNavigate: undefined,
31-
detectBrowserLanguage: undefined
31+
detectBrowserLanguage: undefined,
32+
experimental: {
33+
alternateLinkCanonicalQueries: false
34+
}
3235
}
3336
}
3437
}
@@ -415,13 +418,35 @@ describe('basic usage', async () => {
415418
}
416419
})
417420

418-
const html = await $fetch('/?noncanonical')
421+
const html = await $fetch('/?noncanonical&canonical')
419422
const dom = getDom(html)
420423
await assertLocaleHeadWithDom(dom, '#home-use-locale-head')
421424

422425
const links = getDataFromDom(dom, '#home-use-locale-head').link
423426
const i18nCan = links.find(x => x.id === 'i18n-can')
424427
expect(i18nCan.href).toContain(configDomain)
428+
expect(dom.querySelector('#i18n-alt-fr').href).toEqual(
429+
'https://runtime-config-domain.com/fr?noncanonical&canonical'
430+
)
431+
432+
await restore()
433+
})
434+
435+
test('render seo tags with `experimental.alternateLinkCanonicalQueries`', async () => {
436+
const restore = await startServerWithRuntimeConfig({
437+
public: {
438+
i18n: {
439+
experimental: {
440+
alternateLinkCanonicalQueries: true
441+
}
442+
}
443+
}
444+
})
445+
446+
// head tags - alt links are updated server side
447+
const html = await $fetch('/?noncanonical&canonical')
448+
const dom = getDom(html)
449+
expect(dom.querySelector('#i18n-alt-fr').href).toEqual('http://localhost:3000/fr?canonical=')
425450

426451
await restore()
427452
})
@@ -468,8 +493,10 @@ describe('basic usage', async () => {
468493

469494
// Translated params are not lost on query changes
470495
await page.locator('#params-add-query').click()
471-
await waitForURL(page, '/nl/products/rode-mok?test=123')
472-
expect(await page.locator('#nuxt-locale-link-en').getAttribute('href')).toEqual('/products/red-mug?test=123')
496+
await waitForURL(page, '/nl/products/rode-mok?test=123&canonical=123')
497+
expect(await page.locator('#nuxt-locale-link-en').getAttribute('href')).toEqual(
498+
'/products/red-mug?test=123&canonical=123'
499+
)
473500

474501
await page.locator('#params-remove-query').click()
475502
await waitForURL(page, '/nl/products/rode-mok')

‎specs/basic_usage_compat_4.spec.ts

+31-4
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ describe('basic usage - compatibilityVersion: 4', async () => {
2828
i18n: {
2929
baseUrl: 'http://localhost:3000',
3030
skipSettingLocaleOnNavigate: undefined,
31-
detectBrowserLanguage: undefined
31+
detectBrowserLanguage: undefined,
32+
experimental: {
33+
alternateLinkCanonicalQueries: false
34+
}
3235
}
3336
}
3437
}
@@ -415,13 +418,35 @@ describe('basic usage - compatibilityVersion: 4', async () => {
415418
}
416419
})
417420

418-
const html = await $fetch('/?noncanonical')
421+
const html = await $fetch('/?noncanonical&canonical')
419422
const dom = getDom(html)
420423
await assertLocaleHeadWithDom(dom, '#home-use-locale-head')
421424

422425
const links = getDataFromDom(dom, '#home-use-locale-head').link
423426
const i18nCan = links.find(x => x.id === 'i18n-can')
424427
expect(i18nCan.href).toContain(configDomain)
428+
expect(dom.querySelector('#i18n-alt-fr').href).toEqual(
429+
'https://runtime-config-domain.com/fr?noncanonical&canonical'
430+
)
431+
432+
await restore()
433+
})
434+
435+
test('render seo tags with `experimental.alternateLinkCanonicalQueries`', async () => {
436+
const restore = await startServerWithRuntimeConfig({
437+
public: {
438+
i18n: {
439+
experimental: {
440+
alternateLinkCanonicalQueries: true
441+
}
442+
}
443+
}
444+
})
445+
446+
// head tags - alt links are updated server side
447+
const html = await $fetch('/?noncanonical&canonical')
448+
const dom = getDom(html)
449+
expect(dom.querySelector('#i18n-alt-fr').href).toEqual('http://localhost:3000/fr?canonical=')
425450

426451
await restore()
427452
})
@@ -468,8 +493,10 @@ describe('basic usage - compatibilityVersion: 4', async () => {
468493

469494
// Translated params are not lost on query changes
470495
await page.locator('#params-add-query').click()
471-
await waitForURL(page, '/nl/products/rode-mok?test=123')
472-
expect(await page.locator('#nuxt-locale-link-en').getAttribute('href')).toEqual('/products/red-mug?test=123')
496+
await waitForURL(page, '/nl/products/rode-mok?test=123&canonical=123')
497+
expect(await page.locator('#nuxt-locale-link-en').getAttribute('href')).toEqual(
498+
'/products/red-mug?test=123&canonical=123'
499+
)
473500

474501
await page.locator('#params-remove-query').click()
475502
await waitForURL(page, '/nl/products/rode-mok')

‎specs/experimental/switch_locale_path_link_ssr.spec.ts

+38-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { test, expect, describe } from 'vitest'
22
import { fileURLToPath } from 'node:url'
33
import { $fetch, setup } from '../utils'
4-
import { getDom, gotoPath, renderPage, waitForURL } from '../helper'
4+
import { getDom, gotoPath, renderPage, startServerWithRuntimeConfig, waitForURL } from '../helper'
55

66
await setup({
77
rootDir: fileURLToPath(new URL(`../fixtures/basic_usage`, import.meta.url)),
@@ -12,13 +12,15 @@ await setup({
1212
runtimeConfig: {
1313
public: {
1414
i18n: {
15-
baseUrl: ''
15+
baseUrl: '',
16+
alternateLinkCanonicalQueries: false
1617
}
1718
}
1819
},
1920
i18n: {
2021
experimental: {
21-
switchLocalePathLinkSSR: true
22+
switchLocalePathLinkSSR: true,
23+
alternateLinkCanonicalQueries: false
2224
}
2325
}
2426
}
@@ -35,14 +37,45 @@ describe('experimental.switchLocalePathLinkSSR', async () => {
3537

3638
// Translated params are not lost on query changes
3739
await page.locator('#params-add-query').click()
38-
await waitForURL(page, '/nl/products/rode-mok?test=123')
39-
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual('/products/red-mug?test=123')
40+
await waitForURL(page, '/nl/products/rode-mok?test=123&canonical=123')
41+
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual(
42+
'/products/red-mug?test=123&canonical=123'
43+
)
4044

4145
await page.locator('#params-remove-query').click()
4246
await waitForURL(page, '/nl/products/rode-mok')
4347
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual('/products/red-mug')
4448
})
4549

50+
test('respects `experimental.alternateLinkCanonicalQueries`', async () => {
51+
const restore = await startServerWithRuntimeConfig({
52+
public: {
53+
i18n: {
54+
experimental: {
55+
switchLocalePathLinkSSR: true,
56+
alternateLinkCanonicalQueries: true
57+
}
58+
}
59+
}
60+
})
61+
62+
// head tags - alt links are updated server side
63+
const product1Html = await $fetch('/products/big-chair?test=123&canonical=123')
64+
const product1Dom = getDom(product1Html)
65+
expect(product1Dom.querySelector('#i18n-alt-nl').href).toEqual('/nl/products/grote-stoel?canonical=123')
66+
expect(product1Dom.querySelector('#switch-locale-path-link-nl').href).toEqual(
67+
'/nl/products/grote-stoel?test=123&canonical=123'
68+
)
69+
70+
const product2Html = await $fetch('/nl/products/rode-mok?test=123&canonical=123')
71+
const product2dom = getDom(product2Html)
72+
expect(product2dom.querySelector('#i18n-alt-en').href).toEqual('/products/red-mug?canonical=123')
73+
expect(product2dom.querySelector('#switch-locale-path-link-en').href).toEqual(
74+
'/products/red-mug?test=123&canonical=123'
75+
)
76+
await restore()
77+
})
78+
4679
test('dynamic parameters rendered correctly during SSR', async () => {
4780
// head tags - alt links are updated server side
4881
const product1Html = await $fetch('/products/big-chair')

‎specs/fixtures/basic_usage/layouts/default.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useI18n, useLocaleHead } from '#i18n'
55
66
const route = useRoute()
77
const { t } = useI18n()
8-
const head = useLocaleHead({ key: 'id', seo: { canonicalQueries: ['page'] } })
8+
const head = useLocaleHead({ key: 'id', seo: { canonicalQueries: ['page', 'canonical'] } })
99
const title = computed(() => `Page - ${t(route.meta?.title ?? '')}`)
1010
</script>
1111

‎specs/fixtures/basic_usage/nuxt.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default defineNuxtConfig({
2222
locales: ['en', 'fr'],
2323
defaultLocale: 'en',
2424
experimental: {
25+
alternateLinkCanonicalQueries: false,
2526
autoImportTranslationFunctions: true
2627
}
2728
// debug: true,

‎specs/fixtures/basic_usage/pages/index.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ definePageMeta({
6161
alias: ['/aliased-home-path']
6262
})
6363
64-
const i18nHead = useLocaleHead({ key: 'id', seo: { canonicalQueries: ['page'] } })
64+
const i18nHead = useLocaleHead({ key: 'id', seo: { canonicalQueries: ['page', 'canonical'] } })
6565
useHead(() => ({
6666
htmlAttrs: {
6767
lang: i18nHead.value.htmlAttrs!.lang

‎specs/fixtures/basic_usage/pages/products.vue

+3-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ onMounted(async () => {
1616
<LangSwitcher />
1717
<ul>
1818
<li>
19-
<NuxtLink id="params-add-query" :to="localePath({ query: { test: '123' } })">Add query</NuxtLink>
19+
<NuxtLink id="params-add-query" :to="localePath({ query: { test: '123', canonical: '123' } })"
20+
>Add query</NuxtLink
21+
>
2022
</li>
2123
<li>
2224
<NuxtLink id="params-remove-query" :to="localePath({ query: undefined })">Remove query</NuxtLink>

‎specs/fixtures/basic_usage/pages/products/[slug].vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const product = ref()
66
const { locale } = useI18n()
77
const route = useRoute()
88
9-
const setI18nParams = useSetI18nParams()
9+
const setI18nParams = useSetI18nParams({ canonicalQueries: ['canonical'] })
1010
product.value = await $fetch(`/api/products/${route.params.slug}`)
1111
if (product.value != null) {
1212
const availableLocales = Object.keys(product.value.slugs)

‎specs/fixtures/basic_usage_compat_4/app/layouts/default.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useI18n, useLocaleHead } from '#i18n'
55
66
const route = useRoute()
77
const { t } = useI18n()
8-
const head = useLocaleHead({ key: 'id', seo: { canonicalQueries: ['page'] } })
8+
const head = useLocaleHead({ key: 'id', seo: { canonicalQueries: ['page', 'canonical'] } })
99
const title = computed(() => `Page - ${t(route.meta?.title ?? '')}`)
1010
</script>
1111

‎specs/fixtures/basic_usage_compat_4/app/pages/index.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ definePageMeta({
6161
alias: ['/aliased-home-path']
6262
})
6363
64-
const i18nHead = useLocaleHead({ key: 'id', seo: { canonicalQueries: ['page'] } })
64+
const i18nHead = useLocaleHead({ key: 'id', seo: { canonicalQueries: ['page', 'canonical'] } })
6565
useHead(() => ({
6666
htmlAttrs: {
6767
lang: i18nHead.value.htmlAttrs!.lang

‎specs/fixtures/basic_usage_compat_4/app/pages/products.vue

+3-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ onMounted(async () => {
1616
<LangSwitcher />
1717
<ul>
1818
<li>
19-
<NuxtLink id="params-add-query" :to="localePath({ query: { test: '123' } })">Add query</NuxtLink>
19+
<NuxtLink id="params-add-query" :to="localePath({ query: { test: '123', canonical: '123' } })"
20+
>Add query</NuxtLink
21+
>
2022
</li>
2123
<li>
2224
<NuxtLink id="params-remove-query" :to="localePath({ query: undefined })">Remove query</NuxtLink>

‎specs/fixtures/basic_usage_compat_4/app/pages/products/[slug].vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const product = ref()
66
const { locale } = useI18n()
77
const route = useRoute()
88
9-
const setI18nParams = useSetI18nParams()
9+
const setI18nParams = useSetI18nParams({ canonicalQueries: ['canonical'] })
1010
product.value = await $fetch(`/api/products/${route.params.slug}`)
1111
if (product.value != null) {
1212
const availableLocales = Object.keys(product.value.slugs)

‎specs/fixtures/basic_usage_compat_4/nuxt.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default defineNuxtConfig({
2323
vueI18n: './config/i18n.config.ts',
2424
defaultLocale: 'en',
2525
experimental: {
26+
alternateLinkCanonicalQueries: false,
2627
autoImportTranslationFunctions: true,
2728
localeDetector: './localeDetector.ts'
2829
},

‎src/constants.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export const DEFAULT_OPTIONS = {
3232
autoImportTranslationFunctions: false,
3333
typedPages: true,
3434
typedOptionsAndMessages: false,
35-
generatedLocaleFilePathFormat: 'absolute'
35+
generatedLocaleFilePathFormat: 'absolute',
36+
alternateLinkCanonicalQueries: false
3637
},
3738
bundle: {
3839
compositionOnly: true,

‎src/runtime/composables/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export function useSetI18nParams(seo?: SeoAttributesOptions): SetI18nParamsFunct
9393

9494
// prettier-ignore
9595
metaObject.link.push(
96-
...getHreflangLinks(common, locales, key),
96+
...getHreflangLinks(common, locales, key, seo),
9797
...getCanonicalLink(common, key, seo)
9898
)
9999

‎src/runtime/routing/head.ts

+56-24
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function localeHead(
6262

6363
// Adding SEO Meta
6464
if (seo && locale && unref(nuxtApp.$i18n.locales)) {
65-
metaObject.link.push(...getHreflangLinks(common, locales, key), ...getCanonicalLink(common, key, seo))
65+
metaObject.link.push(...getHreflangLinks(common, locales, key, seo), ...getCanonicalLink(common, key, seo))
6666

6767
metaObject.meta.push(
6868
...getOgUrl(common, key, seo),
@@ -83,7 +83,8 @@ function getBaseUrl() {
8383
export function getHreflangLinks(
8484
common: CommonComposableOptions,
8585
locales: LocaleObject[],
86-
key: NonNullable<I18nHeadOptions['key']>
86+
key: NonNullable<I18nHeadOptions['key']>,
87+
seo: I18nHeadOptions['seo']
8788
) {
8889
const baseUrl = getBaseUrl()
8990
const { defaultLocale, strategy } = useRuntimeConfig().public.i18n
@@ -108,25 +109,45 @@ export function getHreflangLinks(
108109
localeMap.set(localeLanguage, locale)
109110
}
110111

112+
const strictCanonicals = common.runtimeConfig.public.i18n.experimental.alternateLinkCanonicalQueries === true
113+
const routeWithoutQuery = strictCanonicals ? common.router.resolve({ query: {} }) : undefined
114+
115+
// set meta property which is lost on router.resolve
116+
if (!common.runtimeConfig.public.i18n.experimental.switchLocalePathLinkSSR && strictCanonicals) {
117+
routeWithoutQuery!.meta = common.router.currentRoute.value.meta
118+
}
119+
111120
for (const [language, mapLocale] of localeMap.entries()) {
112-
const localePath = switchLocalePath(common, mapLocale.code)
121+
const localePath = switchLocalePath(common, mapLocale.code, routeWithoutQuery)
122+
const canonicalQueryParams = getCanonicalQueryParams(common, seo)
123+
let href = toAbsoluteUrl(localePath, baseUrl)
124+
if (canonicalQueryParams && strictCanonicals) {
125+
href = `${href}?${canonicalQueryParams}`
126+
}
127+
113128
if (localePath) {
114129
links.push({
115130
[key]: `i18n-alt-${language}`,
116131
rel: 'alternate',
117-
href: toAbsoluteUrl(localePath, baseUrl),
132+
href: href,
118133
hreflang: language
119134
})
120135
}
121136
}
122137

123138
if (defaultLocale) {
124-
const localePath = switchLocalePath(common, defaultLocale)
139+
const localePath = switchLocalePath(common, defaultLocale, routeWithoutQuery)
140+
const canonicalQueryParams = getCanonicalQueryParams(common, seo)
141+
let href = toAbsoluteUrl(localePath, baseUrl)
142+
if (canonicalQueryParams && strictCanonicals) {
143+
href = `${href}?${canonicalQueryParams}`
144+
}
145+
125146
if (localePath) {
126147
links.push({
127148
[key]: 'i18n-xd',
128149
rel: 'alternate',
129-
href: toAbsoluteUrl(localePath, baseUrl),
150+
href: href,
130151
hreflang: 'x-default'
131152
})
132153
}
@@ -146,24 +167,9 @@ export function getCanonicalUrl(common: CommonComposableOptions, baseUrl: string
146167
if (!currentRoute) return ''
147168
let href = toAbsoluteUrl(currentRoute.path, baseUrl)
148169

149-
const canonicalQueries = (isObject(seo) && seo.canonicalQueries) || []
150-
const currentRouteQueryParams = currentRoute.query
151-
const params = new URLSearchParams()
152-
for (const queryParamName of canonicalQueries) {
153-
if (queryParamName in currentRouteQueryParams) {
154-
const queryParamValue = currentRouteQueryParams[queryParamName]
155-
156-
if (isArray(queryParamValue)) {
157-
queryParamValue.forEach(v => params.append(queryParamName, v || ''))
158-
} else {
159-
params.append(queryParamName, queryParamValue || '')
160-
}
161-
}
162-
}
163-
164-
const queryString = params.toString()
165-
if (queryString) {
166-
href = `${href}?${queryString}`
170+
const canonicalQueryParams = getCanonicalQueryParams(common, seo)
171+
if (canonicalQueryParams) {
172+
href = `${href}?${canonicalQueryParams}`
167173
}
168174

169175
return href
@@ -181,6 +187,32 @@ export function getCanonicalLink(
181187
return [{ [key]: 'i18n-can', rel: 'canonical', href }]
182188
}
183189

190+
export function getCanonicalQueryParams(common: CommonComposableOptions, seo: I18nHeadOptions['seo']) {
191+
const route = common.router.currentRoute.value
192+
const currentRoute = localeRoute(common, {
193+
...route,
194+
path: undefined,
195+
name: getRouteBaseName(common, route)
196+
})
197+
198+
const canonicalQueries = (isObject(seo) && seo.canonicalQueries) || []
199+
const currentRouteQueryParams = currentRoute?.query || {}
200+
const params = new URLSearchParams()
201+
for (const queryParamName of canonicalQueries) {
202+
if (queryParamName in currentRouteQueryParams) {
203+
const queryParamValue = currentRouteQueryParams[queryParamName]
204+
205+
if (isArray(queryParamValue)) {
206+
queryParamValue.forEach(v => params.append(queryParamName, v || ''))
207+
} else {
208+
params.append(queryParamName, queryParamValue || '')
209+
}
210+
}
211+
}
212+
213+
return params.toString() || undefined
214+
}
215+
184216
export function getOgUrl(
185217
common: CommonComposableOptions,
186218
key: NonNullable<I18nHeadOptions['key']>,

‎src/types.ts

+7
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ export interface ExperimentalFeatures {
127127
* @remark `'relative'` locale file and langDir paths are converted to be relative to the `rootDir`
128128
*/
129129
generatedLocaleFilePathFormat?: 'absolute' | 'relative'
130+
131+
/**
132+
* Removes non-canonical query parameters from alternate link meta tags
133+
*
134+
* @defaultValue `false`
135+
*/
136+
alternateLinkCanonicalQueries?: boolean
130137
}
131138

132139
export interface BundleOptions

0 commit comments

Comments
 (0)
Please sign in to comment.