diff --git a/docs/content/2.guide/19.migrating.md b/docs/content/2.guide/19.migrating.md index 696e2f2a2..600546012 100644 --- a/docs/content/2.guide/19.migrating.md +++ b/docs/content/2.guide/19.migrating.md @@ -134,7 +134,7 @@ Use the `customRoutes` option. because the option name `parsePages` is not intui ### Deprecated `vuex` option -Use `dynamicRouteParams` option. Because Vuex is no longer required by the Nuxt i18n module. +Vuex is no longer required by the Nuxt i18n module, use the `useSetI18nParams` composable to set dynamic route parameters instead. ```diff {}[nuxt.config.js] export default defineNuxtConfig({ @@ -145,7 +145,6 @@ Use `dynamicRouteParams` option. Because Vuex is no longer required by the Nuxt i18n: { // ... - vuex: true, -+ dynamicRouteParams: true, // ... } }) diff --git a/docs/content/2.guide/9.lang-switcher.md b/docs/content/2.guide/9.lang-switcher.md index cd976db2c..1c0ac2371 100644 --- a/docs/content/2.guide/9.lang-switcher.md +++ b/docs/content/2.guide/9.lang-switcher.md @@ -80,57 +80,57 @@ const availableLocales = computed(() => { ## Dynamic route parameters -Dealing with dynamic route parameters requires a bit more work because you need to provide parameters translations to **Nuxt i18n module**. For this purpose, **Nuxt i18n module** uses params which are configured by `definePageMeta`. These will be merged with route params when generating lang switch routes with `switchLocalePath()`. +Dealing with dynamic route parameters requires a bit more work because you need to provide parameters translations to **Nuxt i18n module**. The composable `useSetI18nParams` can be used to set translations for route parameters, this is used to set SEO tags as well as changing the routes returned by `switchLocalePath`. ::alert{type="warning"} +During SSR it matters when and where you set i18n parameters, since there is no reactivity during SSR. -You have to set the `dynamicRouteParams` option to `true` in **Nuxt i18n module**'s options to enable dynamic route parameters. +:br :br +Components which have already been rendered do not update by changes introduced by pages and components further down the tree. Instead, these links are updated on the client side, so this should not cause any issues. +:br :br +This is not the case for SEO tags, these are updated correctly during SSR regardless of when and where i18n parameters are set. :: -An example (replace `id` with the applicable route parameter): - +An example (replace `slug` with the applicable route parameter): ```vue - ``` Note that for the special case of a catch-all route named like `[...pathMatch.vue]`, the key of the object needs to say `pathMatch`. For example: ```vue - ``` Note that a catch all route is defined **as an array**. In this case, there is only one element, but if you want to use a sub-path, for example `/not-found/post`, define multiple elements as in `['not-found', 'post']`. You will need to define more than one, e.g. `['not-found', 'post']`. - ::alert{type="info"} **Nuxt i18n module** won't reset parameters translations for you, this means that if you use identical parameters for different routes, navigating between those routes might result in conflicting parameters. Make sure you always set params translations in such cases. :: @@ -240,3 +240,52 @@ route.meta.pageTransition.onBeforeEnter = async () => { } ``` + + +## Dynamic route parameters using `definePageMeta` (Deprecated) +::alert{type="warning"} +Dynamic route params using `nuxtI18n` on `definePageMeta` has been deprecated and will be removed in `v8.1`, use the composable [`useSetI18nParams`](/api/composables#useseti18nparams) instead. +:: + + +Dynamic params can be configured uding `definePageMeta`. These will be merged with route params when generating lang switch routes with `switchLocalePath()`. + +::alert{type="warning"} +You have to set the `dynamicRouteParams` option to `true` in **Nuxt i18n module**'s options to enable dynamic route parameters. +:: + +An example (replace `id` with the applicable route parameter): + +```vue + + +``` + +Note that for the special case of a catch-all route named like `[...pathMatch.vue]`, the key of the object needs to say `pathMatch`. For example: + +```vue + + +``` \ No newline at end of file diff --git a/docs/content/3.options/2.routing.md b/docs/content/3.options/2.routing.md index 8c6b66afd..5fdfe1402 100644 --- a/docs/content/3.options/2.routing.md +++ b/docs/content/3.options/2.routing.md @@ -132,6 +132,10 @@ Set to a path to which you want to redirect users accessing the root URL (`/`). - type: `boolean` - default: `false` +::alert{type=warning} +The `dynamicRouteParams` is deprecated and will be removed in `v8.1`, use the [`useSetI18nParams`](/api/composables#useseti18nparams) composable instead. +:: + Whether to localize dynamic route parameters. If `true`, you can set the dynamic route parameter to `nuxtI18n` field of `definePageMeta` for each locale: diff --git a/docs/content/4.API/1.composables.md b/docs/content/4.API/1.composables.md index 12f85d3c0..509299262 100644 --- a/docs/content/4.API/1.composables.md +++ b/docs/content/4.API/1.composables.md @@ -124,6 +124,50 @@ An object accepting the following optional fields: Identifier attribute of `` tag, default `'hid'`. + +## `useSetI18nParams` + +The `useSetI18nParams` returns a function to set translated parameters for the current route. For more details on its usage see the [Lang Switcher guide](/guide/lang-switcher#dynamic-route-parameters). + +Example: +```vue + + + +``` + +### Type + +```ts +declare function useSetI18nParams(options?: SeoAttributesOptions): (locale: Record) => void; +``` + +### Parameters + +#### `options` + + **Type**: `SeoAttributesOptions | undefined` + + An `SeoAttributesOptions` object, default `undefined`. See the [SEO guide](/guide/seo#feature-details) for more details. + + + ## `useRouteBaseName` The `useRouteBaseName` composable returns a function that gets the route's base name. `useRouteBaseName` is powered by [vue-i18n-routing](https://github.com/intlify/routing/tree/main/packages/vue-i18n-routing). diff --git a/package.json b/package.json index 0a5c5a0a5..f569f6719 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "@types/debug": "^4.1.9", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", + "@unhead/vue": "^1.8.8", "bumpp": "^9.2.0", "changelogithub": "^0.13.0", "consola": "^3", @@ -130,6 +131,7 @@ "unbuild": "^2.0.0", "undici": "^6.0.1", "vitest": "^1.0.0", + "unhead": "^1.8.8", "vue": "^3.3.4", "vue-router": "^4.2.5" }, @@ -152,4 +154,4 @@ "engines": { "node": "^14.16.0 || >=16.11.0" } -} +} \ No newline at end of file diff --git a/playground/layouts/default.vue b/playground/layouts/default.vue index 42194425f..aa9d56eca 100644 --- a/playground/layouts/default.vue +++ b/playground/layouts/default.vue @@ -1,7 +1,10 @@ @@ -21,7 +24,7 @@ const title = computed(() => t('layouts.title', { title: t(route.meta.title ?? '

I18n Head

- {{ head }} +
{{ head }}
diff --git a/playground/pages/index.vue b/playground/pages/index.vue index accf9d39a..3c05046f2 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -79,9 +79,8 @@ definePageMeta({ Server | Category | History | - Home (Japanese) - - + Home (Japanese) | + Products

Current Language: {{ getLocaleName(locale) }}

Current Strategy: {{ strategy }}

diff --git a/playground/pages/products.vue b/playground/pages/products.vue new file mode 100644 index 000000000..a1d66a8d1 --- /dev/null +++ b/playground/pages/products.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/playground/pages/products/[slug].vue b/playground/pages/products/[slug].vue new file mode 100644 index 000000000..f89dde4c9 --- /dev/null +++ b/playground/pages/products/[slug].vue @@ -0,0 +1,34 @@ + + + + + diff --git a/playground/server/api/locales/[locale].ts b/playground/server/api/locales/[locale].ts index 6ed957935..8759a26a8 100644 --- a/playground/server/api/locales/[locale].ts +++ b/playground/server/api/locales/[locale].ts @@ -7,11 +7,13 @@ import type { LocaleMessages, DefineLocaleMessage } from 'vue-i18n' const locales: LocaleMessages = { 'en-GB': { + id: new Date().toISOString(), settings: { profile: 'Profile' } }, ja: { + id: new Date().toISOString(), layouts: { title: 'ページ ー {title}' }, @@ -26,8 +28,12 @@ const locales: LocaleMessages = { } } -export default defineEventHandler(event => { +export default defineEventHandler(async event => { const locale = event.context.params?.locale + locales['en-GB'].id = new Date().toISOString() + locales['ja'].id = new Date().toISOString() + + await new Promise(resolve => setTimeout(resolve, 5000)) if (locale == null) { return {} } diff --git a/playground/server/api/products-data.ts b/playground/server/api/products-data.ts new file mode 100644 index 000000000..a8c8ab0c8 --- /dev/null +++ b/playground/server/api/products-data.ts @@ -0,0 +1,35 @@ +export default [ + { + id: 1, + name: { + en: 'Red mug', + nl: 'Rode mok' + }, + slugs: { + en: 'red-mug', + nl: 'rode-mok' + } + }, + { + id: 2, + name: { + en: 'Big chair', + nl: 'Grote stoel' + }, + slugs: { + en: 'big-chair', + nl: 'grote-stoel' + } + }, + { + id: 3, + name: { + en: 'Standing desk', + nl: 'Sta bureau' + }, + slugs: { + en: 'standing-desk', + nl: 'sta-bureau' + } + } +] diff --git a/playground/server/api/products.ts b/playground/server/api/products.ts new file mode 100644 index 000000000..ee2225b2c --- /dev/null +++ b/playground/server/api/products.ts @@ -0,0 +1,3 @@ +import productsData from './products-data' + +export default defineEventHandler(event => productsData) diff --git a/playground/server/api/products/[product].ts b/playground/server/api/products/[product].ts new file mode 100644 index 000000000..28c977d0c --- /dev/null +++ b/playground/server/api/products/[product].ts @@ -0,0 +1,14 @@ +import productsData from '../products-data' + +export default defineEventHandler(async event => { + const slug = event.context.params?.product + const found = productsData.find(x => Object.values(x.slugs).includes(slug)) + + // await new Promise(resolve => setTimeout(resolve, 600)) + + if (found == null) { + return {} + } + + return found +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99c4847df..74988ea15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: '@typescript-eslint/parser': specifier: ^6.7.4 version: 6.13.2(eslint@8.55.0)(typescript@5.3.2) + '@unhead/vue': + specifier: ^1.8.8 + version: 1.8.8(vue@3.3.10) bumpp: specifier: ^9.2.0 version: 9.2.0 @@ -167,6 +170,9 @@ importers: undici: specifier: ^6.0.1 version: 6.0.1 + unhead: + specifier: ^1.8.8 + version: 1.8.8 vitest: specifier: ^1.0.0 version: 1.0.1(jsdom@23.0.1) diff --git a/specs/basic_usage.spec.ts b/specs/basic_usage.spec.ts index 41cc73023..08d300f19 100644 --- a/specs/basic_usage.spec.ts +++ b/specs/basic_usage.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from 'vitest' import { fileURLToPath } from 'node:url' import { setup, $fetch } from './utils' -import { assetLocaleHead, getData, getText, gotoPath, renderPage, waitForURL } from './helper' +import { assetLocaleHead, getData, getText, gotoPath, renderPage, waitForURL, getDom } from './helper' await setup({ rootDir: fileURLToPath(new URL(`./fixtures/basic_usage`, import.meta.url)), @@ -319,3 +319,21 @@ test('server integration extended from `layers/layer-server`', async () => { const resQuery = await $fetch('/api/server', { query: { key: 'snakeCaseText', locale: 'fr' } }) expect(resQuery?.snakeCaseText).toMatch('À-propos-de-ce-site') }) + +test('dynamic parameters', async () => { + const { page } = await renderPage('/products/big-chair') + + expect(await page.locator('#nuxt-locale-link-nl').getAttribute('href')).toEqual('/nl/products/grote-stoel') + + await gotoPath(page, '/nl/products/rode-mok') + expect(await page.locator('#nuxt-locale-link-en').getAttribute('href')).toEqual('/products/red-mug') + + // head tags - alt links are updated server side + const product1Html = await $fetch('/products/big-chair') + const product1Dom = getDom(product1Html) + expect(product1Dom.querySelector('#i18n-alt-nl').href).toEqual('/nl/products/grote-stoel') + + const product2Html = await $fetch('/nl/products/rode-mok') + const product2dom = getDom(product2Html) + expect(product2dom.querySelector('#i18n-alt-en').href).toEqual('/products/red-mug') +}) diff --git a/specs/fixtures/basic_usage/app.vue b/specs/fixtures/basic_usage/app.vue index f8eacfa73..b3e68f671 100644 --- a/specs/fixtures/basic_usage/app.vue +++ b/specs/fixtures/basic_usage/app.vue @@ -1,3 +1,9 @@ + +