Skip to content

Commit 4cd36ac

Browse files
authored
feat!: experimental strict internal i18n head management (#3638)
1 parent 568135a commit 4cd36ac

36 files changed

Lines changed: 497 additions & 181 deletions

File tree

docs/content/docs/02.guide/90.migrating.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ definePageMeta({
2828
</script>
2929
```
3030

31+
### Experimental strict SEO mode
32+
We have added a new experimental option `strictSeo`{lang="yml"} that enables strict SEO mode, which changes the way i18n head tags are handled.
33+
34+
With strict SEO mode enabled, the i18n head tags are managed internally, this allows for some much requested improvements:
35+
* The module will no longer add alternate tags for unsupported locales when setting localized dynamic route params.
36+
* Unsupported locale links used with `<SwitchLocalePathLink>`{lang="vue"} are disabled, their links will be set to `'#'`{lang="ts"} and will have a `data-i18n-disabled`{lang="vue"} attribute for styling purposes.
37+
* The `useLocaleHead()`{lang="ts"} is no longer needed in strict SEO mode, i18n tags are automatically set by the module and usage will throw an error.
38+
* Canonical query parameters are configured globally with `experimental.strictSeo.canonicalQueryParams`{lang="yml"}.
39+
* The `useSetI18nParams()`{lang="ts"} inherits the global canonical query parameter config which can be overridden through its options parameter.
40+
41+
If this mode proves stable it will become the default in v11, please try it out and report any issues you encounter.
3142

3243
### Lazy loading
3344
The `lazy` option has been removed and lazy loading of locale messages is now the default behavior.
@@ -36,8 +47,8 @@ The `lazy` option has been removed and lazy loading of locale messages is now th
3647
The function signature for `finalizePendingLocaleChange()`{lang="ts"} has been corrected from `() => Promise<void>`{lang="ts-type"} to `() => void`{lang="ts-type"}.
3748
This change was made since the function does not rely on any async operations and should not be awaited, and should prevent unnecessary function coloring.
3849

39-
### Default arguments changed `useLocaleHead()`{lang="ts"} and `$localeHead()`{lang="ts"}
40-
The default value for the `key` property has been changed from `'hid'` to `'key'`.
50+
### Arguments changed `useLocaleHead()`{lang="ts"} and `$localeHead()`{lang="ts"}
51+
The `key` property has been removed and can no longer be configured, this is necessary for predictable and consistent localized head tag management.
4152

4253
### `restructureDir` migration path removed
4354
To ease migration in v9 it was possible to disable the new directory structure by setting `restructureDir: false`, this has now been removed and we recommend using the default value of `'i18n'`.

docs/content/docs/04.api/00.options.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,12 @@ This feature relies on [Nuxt's `experimental.typedRoutes`](https://nuxt.com/docs
480480
This functionality is only supported for projects using vite.
481481
::
482482

483+
## `strictSeo`
484+
485+
- type: `boolean | SeoAttributesOptions`{lang="ts-type"}
486+
- default: `false`{lang="ts"}
487+
- Enables strict SEO mode.
488+
483489
## customBlocks
484490

485491
Configure the `i18n` custom blocks of SFC.

pnpm-lock.yaml

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

specs/basic-usage-tests.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, test, expect } from 'vitest'
1+
import { test, expect } from 'vitest'
22
import { $fetch, url } from './utils'
33
import {
44
assertLocaleHeadWithDom,
@@ -142,7 +142,7 @@ export function basicUsageTests() {
142142
)
143143

144144
expect(await page.locator('#locale-head').innerText()).toMatchInlineSnapshot(
145-
`"{ "htmlAttrs": { "lang": "en" }, "link": [ { "key": "i18n-xd", "rel": "alternate", "href": "http://localhost:3000/nuxt-context-extension", "hreflang": "x-default" }, { "key": "i18n-alt-en", "rel": "alternate", "href": "http://localhost:3000/nuxt-context-extension", "hreflang": "en" }, { "key": "i18n-alt-fr", "rel": "alternate", "href": "http://localhost:3000/fr/nuxt-context-extension", "hreflang": "fr" }, { "key": "i18n-alt-ja", "rel": "alternate", "href": "http://localhost:3000/ja/nuxt-context-extension", "hreflang": "ja" }, { "key": "i18n-alt-ja-JP", "rel": "alternate", "href": "http://localhost:3000/ja/nuxt-context-extension", "hreflang": "ja-JP" }, { "key": "i18n-alt-nl", "rel": "alternate", "href": "http://localhost:3000/nl/nuxt-context-extension", "hreflang": "nl" }, { "key": "i18n-alt-nl-NL", "rel": "alternate", "href": "http://localhost:3000/nl/nuxt-context-extension", "hreflang": "nl-NL" }, { "key": "i18n-alt-nl-BE", "rel": "alternate", "href": "http://localhost:3000/be/nuxt-context-extension", "hreflang": "nl-BE" }, { "key": "i18n-alt-kr", "rel": "alternate", "href": "http://localhost:3000/kr/nuxt-context-extension", "hreflang": "kr" }, { "key": "i18n-alt-kr-KO", "rel": "alternate", "href": "http://localhost:3000/kr/nuxt-context-extension", "hreflang": "kr-KO" }, { "key": "i18n-can", "rel": "canonical", "href": "http://localhost:3000/nuxt-context-extension" } ], "meta": [ { "key": "i18n-og-url", "property": "og:url", "content": "http://localhost:3000/nuxt-context-extension" }, { "key": "i18n-og", "property": "og:locale", "content": "en" }, { "key": "i18n-og-alt-fr", "property": "og:locale:alternate", "content": "fr" }, { "key": "i18n-og-alt-ja-JP", "property": "og:locale:alternate", "content": "ja_JP" }, { "key": "i18n-og-alt-nl-NL", "property": "og:locale:alternate", "content": "nl_NL" }, { "key": "i18n-og-alt-nl-BE", "property": "og:locale:alternate", "content": "nl_BE" }, { "key": "i18n-og-alt-kr-KO", "property": "og:locale:alternate", "content": "kr_KO" } ] }"`
145+
`"{ "htmlAttrs": { "lang": "en" }, "link": [ { "id": "i18n-xd", "rel": "alternate", "href": "http://localhost:3000/nuxt-context-extension", "hreflang": "x-default" }, { "id": "i18n-alt-en", "rel": "alternate", "href": "http://localhost:3000/nuxt-context-extension", "hreflang": "en" }, { "id": "i18n-alt-fr", "rel": "alternate", "href": "http://localhost:3000/fr/nuxt-context-extension", "hreflang": "fr" }, { "id": "i18n-alt-ja", "rel": "alternate", "href": "http://localhost:3000/ja/nuxt-context-extension", "hreflang": "ja" }, { "id": "i18n-alt-ja-JP", "rel": "alternate", "href": "http://localhost:3000/ja/nuxt-context-extension", "hreflang": "ja-JP" }, { "id": "i18n-alt-nl", "rel": "alternate", "href": "http://localhost:3000/nl/nuxt-context-extension", "hreflang": "nl" }, { "id": "i18n-alt-nl-NL", "rel": "alternate", "href": "http://localhost:3000/nl/nuxt-context-extension", "hreflang": "nl-NL" }, { "id": "i18n-alt-nl-BE", "rel": "alternate", "href": "http://localhost:3000/be/nuxt-context-extension", "hreflang": "nl-BE" }, { "id": "i18n-alt-kr", "rel": "alternate", "href": "http://localhost:3000/kr/nuxt-context-extension", "hreflang": "kr" }, { "id": "i18n-alt-kr-KO", "rel": "alternate", "href": "http://localhost:3000/kr/nuxt-context-extension", "hreflang": "kr-KO" }, { "id": "i18n-can", "rel": "canonical", "href": "http://localhost:3000/nuxt-context-extension" } ], "meta": [ { "id": "i18n-og-url", "property": "og:url", "content": "http://localhost:3000/nuxt-context-extension" }, { "id": "i18n-og", "property": "og:locale", "content": "en" }, { "id": "i18n-og-alt-fr", "property": "og:locale:alternate", "content": "fr" }, { "id": "i18n-og-alt-ja-JP", "property": "og:locale:alternate", "content": "ja_JP" }, { "id": "i18n-og-alt-nl-NL", "property": "og:locale:alternate", "content": "nl_NL" }, { "id": "i18n-og-alt-nl-BE", "property": "og:locale:alternate", "content": "nl_BE" }, { "id": "i18n-og-alt-kr-KO", "property": "og:locale:alternate", "content": "kr_KO" } ] }"`
146146
)
147147
})
148148

@@ -426,14 +426,16 @@ export function basicUsageTests() {
426426
// head tags - alt links are updated server side
427427
const html = await $fetch('/?noncanonical&canonical')
428428
const dom = getDom(html)
429-
expect(dom.querySelector('#i18n-alt-fr')?.getAttribute('href')).toEqual('http://localhost:3000/fr?canonical=')
429+
expect(dom.querySelector('link[id=i18n-alt-fr]')?.getAttribute('href')).toEqual(
430+
'http://localhost:3000/fr?canonical='
431+
)
430432
})
431433

432434
test('respects `experimental.alternateLinkCanonicalQueries`', async () => {
433435
// head tags - alt links are updated server side
434436
const product1Html = await $fetch('/products/big-chair?test=123&canonical=123')
435437
const product1Dom = getDom(product1Html)
436-
expect(product1Dom.querySelector('#i18n-alt-nl')?.getAttribute('href')).toEqual(
438+
expect(product1Dom.querySelector('link[id=i18n-alt-nl]')?.getAttribute('href')).toEqual(
437439
'http://localhost:3000/nl/products/grote-stoel?canonical=123'
438440
)
439441
expect(product1Dom.querySelector('#switch-locale-path-link-nl')?.getAttribute('href')).toEqual(
@@ -442,7 +444,7 @@ export function basicUsageTests() {
442444

443445
const product2Html = await $fetch('/nl/products/rode-mok?test=123&canonical=123')
444446
const product2dom = getDom(product2Html)
445-
expect(product2dom.querySelector('#i18n-alt-en')?.getAttribute('href')).toEqual(
447+
expect(product2dom.querySelector('link[id=i18n-alt-en]')?.getAttribute('href')).toEqual(
446448
'http://localhost:3000/products/red-mug?canonical=123'
447449
)
448450
expect(product2dom.querySelector('#switch-locale-path-link-en')?.getAttribute('href')).toEqual(
@@ -454,7 +456,7 @@ export function basicUsageTests() {
454456
// head tags - alt links are updated server side
455457
const product1Html = await $fetch('/products/big-chair')
456458
const product1Dom = getDom(product1Html)
457-
expect(product1Dom.querySelector('#i18n-alt-nl')?.getAttribute('href')).toEqual(
459+
expect(product1Dom.querySelector('link[id=i18n-alt-nl]')?.getAttribute('href')).toEqual(
458460
'http://localhost:3000/nl/products/grote-stoel'
459461
)
460462
expect(product1Dom.querySelector('#switch-locale-path-link-nl')?.getAttribute('href')).toEqual(
@@ -463,7 +465,7 @@ export function basicUsageTests() {
463465

464466
const product2Html = await $fetch('/nl/products/rode-mok')
465467
const product2dom = getDom(product2Html)
466-
expect(product2dom.querySelector('#i18n-alt-en')?.getAttribute('href')).toEqual(
468+
expect(product2dom.querySelector('link[id=i18n-alt-en]')?.getAttribute('href')).toEqual(
467469
'http://localhost:3000/products/red-mug'
468470
)
469471
expect(product2dom.querySelector('#switch-locale-path-link-en')?.getAttribute('href')).toEqual('/products/red-mug')
@@ -511,35 +513,35 @@ export function basicUsageTests() {
511513
test('dynamic parameters', async () => {
512514
const { page } = await renderPage('/products/big-chair')
513515

514-
expect(await page.locator('#nuxt-locale-link-nl').getAttribute('href')).toEqual('/nl/products/grote-stoel')
516+
expect(await page.locator('#switch-locale-path-link-nl').getAttribute('href')).toEqual('/nl/products/grote-stoel')
515517

516518
await gotoPath(page, '/nl/products/rode-mok')
517519
await page.waitForFunction(
518-
() => document.querySelector('#nuxt-locale-link-en')?.getAttribute('href') === '/products/red-mug'
520+
() => document.querySelector('#switch-locale-path-link-en')?.getAttribute('href') === '/products/red-mug'
519521
)
520-
expect(await page.locator('#nuxt-locale-link-en').getAttribute('href')).toEqual('/products/red-mug')
522+
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual('/products/red-mug')
521523

522524
// Translated params are not lost on query changes
523525
await page.locator('#params-add-query').clickNavigate()
524526
await page.waitForURL(url('/nl/products/rode-mok?test=123&canonical=123'))
525-
expect(await page.locator('#nuxt-locale-link-en').getAttribute('href')).toEqual(
527+
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual(
526528
'/products/red-mug?test=123&canonical=123'
527529
)
528530

529531
await page.locator('#params-remove-query').clickNavigate()
530532
await page.waitForURL(url('/nl/products/rode-mok'))
531-
expect(await page.locator('#nuxt-locale-link-en').getAttribute('href')).toEqual('/products/red-mug')
533+
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual('/products/red-mug')
532534

533535
// head tags - alt links are updated server side
534536
const product1Html = await $fetch('/products/big-chair')
535537
const product1Dom = getDom(product1Html)
536-
expect(product1Dom.querySelector('#i18n-alt-nl')?.getAttribute('href')).toEqual(
538+
expect(product1Dom.querySelector('link[id=i18n-alt-nl]')?.getAttribute('href')).toEqual(
537539
'http://localhost:3000/nl/products/grote-stoel'
538540
)
539541

540542
const product2Html = await $fetch('/nl/products/rode-mok')
541543
const product2dom = getDom(product2Html)
542-
expect(product2dom.querySelector('#i18n-alt-en')?.getAttribute('href')).toEqual(
544+
expect(product2dom.querySelector('link[id=i18n-alt-en]')?.getAttribute('href')).toEqual(
543545
'http://localhost:3000/products/red-mug'
544546
)
545547
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { test, expect, describe } from 'vitest'
2+
import { fileURLToPath } from 'node:url'
3+
import { $fetch, setup, url } from '../utils'
4+
import { getDom, getHeadSnapshot, renderPage } from '../helper'
5+
6+
await setup({
7+
rootDir: fileURLToPath(new URL(`../fixtures/basic_usage`, import.meta.url)),
8+
browser: true,
9+
// prerender: true,
10+
// overrides
11+
nuxtConfig: {
12+
i18n: {
13+
experimental: {
14+
strictSeo: true
15+
}
16+
}
17+
}
18+
})
19+
20+
describe('experimental.strictSeo', async () => {
21+
test('dynamic parameters rendered correctly during SSR', async () => {
22+
const { page } = await renderPage('/')
23+
await page.goto(url('/products/big-chair'))
24+
expect(await page.locator('#switch-locale-path-link-nl').getAttribute('href')).toEqual('/nl/products/grote-stoel')
25+
expect(await getHeadSnapshot(page)).toMatchInlineSnapshot(`
26+
"HTML:
27+
lang: en
28+
dir: ltr
29+
Link:
30+
canonical: http://localhost:3000/products/big-chair
31+
alternate[x-default]: http://localhost:3000/products/big-chair
32+
alternate[en]: http://localhost:3000/products/big-chair
33+
alternate[fr]: http://localhost:3000/fr/products/french-chair
34+
alternate[ja]: http://localhost:3000/ja/products/japanese-chair
35+
alternate[ja-JP]: http://localhost:3000/ja/products/japanese-chair
36+
alternate[nl]: http://localhost:3000/nl/products/grote-stoel
37+
alternate[nl-NL]: http://localhost:3000/nl/products/grote-stoel
38+
Meta:
39+
og:url: http://localhost:3000/products/big-chair
40+
og:locale: en
41+
og:locale:alternate: fr, ja, ja_JP, nl, nl_NL"
42+
`)
43+
44+
await page.goto(url('/nl/products/rode-mok'))
45+
expect(await page.locator('#switch-locale-path-link-en').getAttribute('href')).toEqual('/products/red-mug')
46+
expect(await page.locator('#switch-locale-path-link-ja[data-i18n-disabled]').getAttribute('href')).toEqual('#')
47+
expect(await getHeadSnapshot(page)).toMatchInlineSnapshot(`
48+
"HTML:
49+
lang: nl-NL
50+
dir: ltr
51+
Link:
52+
canonical: http://localhost:3000/nl/products/rode-mok
53+
alternate[x-default]: http://localhost:3000/products/red-mug
54+
alternate[en]: http://localhost:3000/products/red-mug
55+
alternate[fr]: http://localhost:3000/fr/products/french-mug
56+
alternate[nl]: http://localhost:3000/nl/products/rode-mok
57+
alternate[nl-NL]: http://localhost:3000/nl/products/rode-mok
58+
Meta:
59+
og:url: http://localhost:3000/nl/products/rode-mok
60+
og:locale: nl_NL
61+
og:locale:alternate: en, fr, nl"
62+
`)
63+
})
64+
})

specs/fixtures/basic/pages/index.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import LangSwitcher from '../components/LangSwitcher.vue'
77
88
const { t, locale } = useI18n()
99
const localePath = useLocalePath()
10-
const i18nHead = useLocaleHead({ key: 'id', seo: { canonicalQueries: ['page'] } })
10+
const i18nHead = useLocaleHead({ seo: { canonicalQueries: ['page'] } })
1111
const { data, refresh } = useAsyncData(`home-${locale.value}`, () =>
1212
Promise.resolve({
1313
aboutPath: localePath('about'),

specs/fixtures/basic_usage/app.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,11 @@ section {
3030
.my-leave-active {
3131
opacity: 0;
3232
}
33+
34+
a[data-i18n-disabled] {
35+
/* display: none; */
36+
color: #d54141;
37+
pointer-events: none;
38+
user-select: none;
39+
}
3340
</style>

specs/fixtures/basic_usage/components/LangSwitcher.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const localesExcludingCurrent = computed(() => {
1313

1414
<template>
1515
<div>
16-
<section id="lang-switcher-with-nuxt-link">
16+
<section v-if="!$nuxt.$config.public.i18n.experimental.strictSeo" id="lang-switcher-with-nuxt-link">
1717
<strong>Using <code>NuxtLink</code></strong
1818
>:
1919
<NuxtLink

0 commit comments

Comments
 (0)