Skip to content

Commit e07063e

Browse files
authored
fix(seo): warn when a domain setup has no locale to annotate as x-default (#4092)
1 parent f0e5a46 commit e07063e

3 files changed

Lines changed: 72 additions & 4 deletions

File tree

docs/content/docs/02.guide/10.multi-domain-locales.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ How to set up multi domain locales:
99
- Configure the `locales` option as an array of objects:
1010
- Each object has a `domains` key whose value is a array of the domains you'd like to use for that locale. Optionally include a port (if non-standard) and/or a protocol. If the protocol is not provided then an attempt will be made to auto-detect it but that might not work correctly in some cases like when the pages are statically generated.
1111
- Optionally set for each object a `defaultForDomains` key whose value is a array of the default domains you'd like to use for that locale. Optionally include a port (if non-standard) and/or a protocol. If the protocol is not provided then an attempt will be made to auto-detect it but that might not work correctly in some cases like when the pages are statically generated.
12+
- Optionally set `defaultLocale`. Each domain resolves its own unprefixed locale from `defaultForDomains`, this names the fallback for the cluster as a whole - see [`defaultLocale` and `x-default`](#defaultlocale-and-x-default).
1213
- Optionally set `detectBrowserLanguage` to `false`{lang="ts"}. When enabled (which it is by default), a first visit can be redirected to the locale detected from the browser, within the current domain. Set to `false`{lang="ts"} if you want to ensure that visiting a given domain always shows the page in that domain's own locale.
1314

1415
```ts [nuxt.config.ts]
@@ -51,6 +52,7 @@ export default defineNuxtConfig({
5152
domains: i18nDomains
5253
},
5354
],
55+
defaultLocale: 'en',
5456
multiDomainLocales: true
5557
}
5658
})
@@ -213,3 +215,9 @@ Given the above configuration, following requests will be:
213215
Locales served on other domains stay available in `locales` and in the locale switcher, `switchLocalePath` links to them on the domain that serves them. Browser language detection only applies locales served on the current domain, so a visitor whose browser or cookie locale isn't available there keeps that domain's own locale.
214216

215217
A request on a host that doesn't match any configured domain, such as a staging domain or a health check by IP, isn't restricted. Every locale is served there and `defaultLocale` is used as the unprefixed default, so it's worth setting even when every domain has its own default through `defaultForDomains`.
218+
219+
## `defaultLocale` and `x-default`
220+
221+
The domains are annotated as one cluster, each page links to its alternates on the other domains. A cluster has a single fallback for unmatched languages, so the `x-default` alternate is taken from `defaultLocale` rather than from the locale a domain happens to default to - otherwise every domain would name a different one.
222+
223+
`defaultLocale` is optional here, since each domain resolves its own unprefixed locale through `defaultForDomains`. Leaving it out means no `x-default` is annotated at all, which is allowed but drops a signal for visitors whose language matches none of your locales. A warning is logged when it isn't set.

src/runtime/routing/head.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ function patchHead(head: ComposableContext['head'] | undefined, input: I18nHeadM
1212
head?.patch(input)
1313
}
1414

15+
/**
16+
* Alternate links annotate the domains as one cluster, and a cluster has a single fallback for
17+
* unmatched languages. It cannot be resolved from the current domain - every domain would name a
18+
* different one - so `x-default` is left out until `defaultLocale` names it.
19+
*/
20+
export function missesClusterFallback(ctx: ComposableContext, config: Required<I18nHeadOptions>): boolean {
21+
const { domains, hreflangLinks, defaultLocale } = ctx.routingOptions
22+
return !!config.seo && domains && hreflangLinks && !defaultLocale
23+
}
24+
25+
let warnedClusterFallback = false
26+
1527
function createHeadContext(
1628
ctx: ComposableContext,
1729
config: Required<I18nHeadOptions>,
@@ -25,6 +37,11 @@ function createHeadContext(
2537
// deduplicate, layered configs merge `canonicalQueries` arrays with duplicate entries
2638
const canonicalQueries = [...new Set((typeof config.seo === 'object' && config.seo?.canonicalQueries) || [])]
2739

40+
if (import.meta.dev && !warnedClusterFallback && missesClusterFallback(ctx, config)) {
41+
warnedClusterFallback = true
42+
console.warn('[nuxt-i18n] Set `defaultLocale` to annotate an `x-default` alternate for your domains.')
43+
}
44+
2845
if (!baseUrl && !ctx.routingOptions.domains) {
2946
if (ctx.strictSeo) {
3047
throw new Error('I18n `baseUrl` is required to generate valid SEO tag links.')

test/routing-head.test.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { nextTick } from 'vue'
33
import { createMemoryHistory, createRouter } from 'vue-router'
44
import { createRoutingContext } from '../src/runtime/routing/context'
55
import { setupMultiDomainLocales } from '../src/runtime/routing/domain'
6-
import { _useLocaleHead, _useSetI18nParams, localeHead } from '../src/runtime/routing/head'
6+
import { _useLocaleHead, _useSetI18nParams, localeHead, missesClusterFallback } from '../src/runtime/routing/head'
77
import { switchLocalePath } from '../src/runtime/routing/routing'
88
import { headEntries } from './mocks/imports'
9+
import { resolveDefaultLocale } from '../src/runtime/shared/locales'
910

1011
import type { Router } from 'vue-router'
1112
import type { ComposableContext } from '../src/runtime/composable-context'
@@ -30,7 +31,7 @@ const routes = [
3031
})),
3132
)
3233

33-
function createTestContext(initialLocale = 'en', strictSeo = false, domains = false) {
34+
function createTestContext(initialLocale = 'en', strictSeo = false, domains = false, configuredDefault = 'en') {
3435
let locale = initialLocale
3536
const router = createRouter({ history: createMemoryHistory(), routes })
3637
const head = { patches: [] as I18nHeadMetaInfo[], patch(val: I18nHeadMetaInfo) { this.patches.push(val) } }
@@ -43,10 +44,12 @@ function createTestContext(initialLocale = 'en', strictSeo = false, domains = fa
4344
// rebuild the route table for the current host, mirrors the runtime plugin
4445
setupMultiDomainLocales(initialLocale, 'prefix_except_default', router)
4546
}
47+
const host = domains ? `${initialLocale}.example.com` : 'example.com'
4648
const ctx = {
4749
...createRoutingContext({
4850
router,
49-
defaultLocale: 'en',
51+
// resolved the way the runtime plugin does, rather than supplied
52+
defaultLocale: resolveDefaultLocale(host, configuredDefault, domains ? domainLocales : locales),
5053
strategy: 'prefix_except_default',
5154
routing: true,
5255
domains,
@@ -65,7 +68,13 @@ function createTestContext(initialLocale = 'en', strictSeo = false, domains = fa
6568
metaState: { htmlAttrs: {}, meta: [], link: [] },
6669
seoSettings: { dir: true, lang: true, seo: true },
6770
localePathPayload: {},
68-
routingOptions: { defaultLocale: 'en', strictCanonicals: true, hreflangLinks: true, domains },
71+
routingOptions: {
72+
// `createComposableContext` feeds `x-default` the configured value, not the host's default
73+
defaultLocale: configuredDefault || '',
74+
strictCanonicals: true,
75+
hreflangLinks: true,
76+
domains,
77+
},
6978
} as unknown as ComposableContext
7079
return { router, ctx, head, setLocale: (l: string) => (locale = l) }
7180
}
@@ -147,6 +156,40 @@ describe('localeHead with domains', () => {
147156
['og:locale:alternate', 'nl_NL'],
148157
])
149158
})
159+
160+
test('every domain annotates the same `x-default`', async () => {
161+
for (const host of ['en', 'fr', 'nl']) {
162+
const { router, ctx } = createTestContext(host, false, true)
163+
await router.push('/')
164+
165+
const xDefault = localeHead(ctx, {}).link.find(x => x.hreflang === 'x-default')
166+
expect(xDefault?.href).toBe('https://en.example.com')
167+
}
168+
})
169+
170+
test('a missing cluster fallback is only worth reporting where alternates are emitted', () => {
171+
const seo = { dir: true, lang: true, seo: true }
172+
const withDomains = (configuredDefault: string) => createTestContext('fr', false, true, configuredDefault).ctx
173+
174+
expect(missesClusterFallback(withDomains(''), seo)).toBe(true)
175+
// `defaultLocale` names the fallback
176+
expect(missesClusterFallback(withDomains('en'), seo)).toBe(false)
177+
// no alternate links to annotate
178+
expect(missesClusterFallback(withDomains(''), { ...seo, seo: false })).toBe(false)
179+
// a single domain cluster resolves `x-default` from the routing default as before
180+
expect(missesClusterFallback(createTestContext('fr', false, false, '').ctx, seo)).toBe(false)
181+
})
182+
183+
test('no configured `defaultLocale` annotates no fallback, rather than one per domain', async () => {
184+
// the domain default is host-resolved and would disagree across the cluster, `prepareOptions`
185+
// warns instead - a locale has no way to claim the cluster fallback on its own
186+
const { router, ctx } = createTestContext('fr', false, true, '')
187+
await router.push('/')
188+
189+
const links = localeHead(ctx, {}).link
190+
expect(links.filter(x => x.hreflang === 'x-default')).toEqual([])
191+
expect(links.map(x => x.hreflang ?? x.rel)).toContain('fr')
192+
})
150193
})
151194

152195
describe('switchLocalePath', () => {

0 commit comments

Comments
 (0)