From 543db08954bcc61784f5391417b5137041200e30 Mon Sep 17 00:00:00 2001 From: Bobbie Goede Date: Wed, 29 Nov 2023 11:08:32 +0100 Subject: [PATCH 01/16] feat: add `useSetI18nParams` composable --- package.json | 4 +- pnpm-lock.yaml | 6 ++ src/runtime/composables/index.ts | 71 +++++++++++++- src/runtime/plugins/i18n.ts | 14 ++- src/runtime/utils.ts | 163 ++++++++++++++++++++++++++++++- 5 files changed, 247 insertions(+), 11 deletions(-) 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/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/src/runtime/composables/index.ts b/src/runtime/composables/index.ts index 06136106b..2f74135b8 100644 --- a/src/runtime/composables/index.ts +++ b/src/runtime/composables/index.ts @@ -1,10 +1,13 @@ -import { useRoute, useRouter, useRequestHeaders, useCookie, useNuxtApp } from '#imports' +import { useRoute, useRouter, useRequestHeaders, useCookie, useNuxtApp, useState } from '#imports' import { ref } from 'vue' import { parseAcceptLanguage } from '../internal' import { nuxtI18nInternalOptions, nuxtI18nOptionsDefault, localeCodes as _localeCodes } from '#build/i18n.options.mjs' +import { getActiveHead } from 'unhead' import { getComposer, findBrowserLocale, + getLocale, + getLocales, useRouteBaseName as _useRouteBaseName, useLocalePath as _useLocalePath, useLocaleRoute as _useLocaleRoute, @@ -18,7 +21,71 @@ import type { DetectBrowserLanguageOptions } from '#build/i18n.options.mjs' export * from 'vue-i18n' export * from './shared' export type { LocaleObject } from 'vue-i18n-routing' -import type { Locale } from 'vue-i18n' +import { type Locale, type LocaleMessages, type DefineLocaleMessage, type I18nOptions, useI18n } from 'vue-i18n' +import type { LocaleObject } from 'vue-i18n-routing' +import { + addAlternateOgLocales, + addCanonicalLinksAndOgUrl, + addCurrentOgLocale, + addHreflangLinks, + getNormalizedLocales, + type HeadParam +} from '../utils' + +/** + * Returns a function to set i18n params. + * + * @param options - An options, see about details {@link I18nHeadOptions}. + * + * @returns setI18nParams {@link I18nHeadMetaInfo | head properties}. + * + * @public + */ +export function useSetI18nParams( + options?: Pick< + NonNullable[0]>, + 'addDirAttribute' | 'addSeoAttributes' | 'identifierAttribute' | 'route' | 'router' | 'i18n' + > +) { + const i18n = useI18n() + const head = getActiveHead() + const locale = getLocale(i18n) + const locales = getNormalizedLocales(getLocales(i18n)) + const metaState = useState>('nuxt-i18n-meta') + const addDirAttribute = options?.addDirAttribute ?? true + const addSeoAttributes = options?.addSeoAttributes ?? true + const idAttribute = options?.identifierAttribute ?? 'id' + + const currentLocale = getNormalizedLocales(locales).find(l => l.code === locale) || { code: locale } + const currentLocaleIso = currentLocale.iso + const currentLocaleDir = currentLocale.dir || i18n.defaultDirection + + const setMeta = () => { + const metaObject: HeadParam = { + htmlAttrs: { + dir: addDirAttribute ? currentLocaleDir : undefined, + lang: addSeoAttributes && locale && i18n.locales ? currentLocaleIso : undefined + }, + link: [], + meta: [] + } + + // Adding SEO Meta + if (addSeoAttributes && locale && i18n.locales) { + addHreflangLinks(locales as LocaleObject[], metaObject, idAttribute) + addCanonicalLinksAndOgUrl(metaObject, idAttribute, addSeoAttributes) + addCurrentOgLocale(currentLocale, currentLocaleIso, metaObject, idAttribute) + addAlternateOgLocales(locales as LocaleObject[], currentLocaleIso, metaObject, idAttribute) + } + + head?.push(metaObject) + } + + return function (params: Record) { + metaState.value = { ...params } + setMeta() + } +} /** * The `useRouteBaseName` composable returns a function that gets the route's base name. diff --git a/src/runtime/plugins/i18n.ts b/src/runtime/plugins/i18n.ts index c8ae768c6..bbe96d0e0 100644 --- a/src/runtime/plugins/i18n.ts +++ b/src/runtime/plugins/i18n.ts @@ -1,4 +1,4 @@ -import { computed } from 'vue' +import { computed, ref } from 'vue' import { createI18n } from 'vue-i18n' import { createLocaleFromRouteGetter, @@ -13,7 +13,14 @@ import { getLocale, getComposer } from 'vue-i18n-routing' -import { defineNuxtPlugin, useRouter, useRoute, addRouteMiddleware, defineNuxtRouteMiddleware } from '#imports' +import { + defineNuxtPlugin, + useRouter, + useRoute, + addRouteMiddleware, + defineNuxtRouteMiddleware, + useState +} from '#imports' import { localeCodes, vueI18nConfigs, @@ -104,7 +111,8 @@ export default defineNuxtPlugin({ ...nuxtI18nOptions, dynamicRouteParamsKey: 'nuxtI18n', switchLocalePathIntercepter: extendSwitchLocalePathIntercepter(differentDomains, normalizedLocales, nuxtContext), - prefixable: extendPrefixable(differentDomains) + prefixable: extendPrefixable(differentDomains), + dynamicParamsInterceptor: () => useState('nuxt-i18n-meta', () => ref({})) }) const getDefaultLocale = (defaultLocale: string) => defaultLocale || vueI18nOptions.locale || 'en-US' diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 9848846b8..3145be52e 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ - +import { useI18n } from 'vue-i18n' import { getLocale, setLocale, @@ -13,11 +13,14 @@ import { DefaultPrefixable, DefaultSwitchLocalePathIntercepter, getComposer, - useSwitchLocalePath + useLocaleRoute, + useRouteBaseName, + useSwitchLocalePath, + STRATEGIES } from 'vue-i18n-routing' import { joinURL, isEqual } from 'ufo' -import { isString, isFunction, isObject } from '@intlify/shared' -import { navigateTo, useState } from '#imports' +import { isString, isFunction, isArray, isObject } from '@intlify/shared' +import { navigateTo, useRoute, useState } from '#imports' import { nuxtI18nInternalOptions, nuxtI18nOptionsDefault, NUXT_I18N_MODULE_ID, isSSG } from '#build/i18n.options.mjs' import { detectBrowserLanguage, @@ -39,7 +42,8 @@ import type { RouteLocationNormalizedLoaded, BaseUrlResolveHandler, PrefixableOptions, - SwitchLocalePathIntercepter + SwitchLocalePathIntercepter, + I18nHeadOptions } from 'vue-i18n-routing' import type { I18n, I18nOptions, Locale, FallbackLocale, LocaleMessages, DefineLocaleMessage } from 'vue-i18n' import type { NuxtApp } from '#app' @@ -47,6 +51,7 @@ import type { NuxtI18nOptions, DetectBrowserLanguageOptions, RootRedirectOptions import type { DeepRequired } from 'ts-essentials' import type { DetectLocaleContext } from './internal' import type { LocaleLoader as LocaleInternalLoader } from './messages' +import type { HeadSafe } from '@unhead/vue' export function _setLocale(i18n: I18n, locale: Locale) { return callVueI18nInterfaces(i18n, 'setLocale', locale) @@ -491,3 +496,151 @@ export function extendBaseUrl( } /* eslint-enable @typescript-eslint/no-explicit-any */ + +export type HeadParam = Required> +type IdParam = NonNullable + +export function addHreflangLinks(locales: LocaleObject[], head: HeadParam, idAttribute: IdParam) { + const { defaultLocale, strategy, baseUrl } = useI18n() + const switchLocalePath = useSwitchLocalePath() + + if (strategy === STRATEGIES.NO_PREFIX) { + return + } + + const localeMap = new Map() + const links = [] + for (const locale of locales) { + const localeIso = locale.iso + + if (!localeIso) { + console.warn('Locale ISO code is required to generate alternate link') + continue + } + + const [language, region] = localeIso.split('-') + if (language && region && (locale.isCatchallLocale || !localeMap.has(language))) { + localeMap.set(language, locale) + } + + localeMap.set(localeIso, locale) + } + + for (const [iso, mapLocale] of localeMap.entries()) { + const localePath = switchLocalePath(mapLocale.code) + if (localePath) { + links.push({ + [idAttribute]: `i18n-alt-${iso}`, + rel: 'alternate', + href: toAbsoluteUrl(localePath, baseUrl.value), + hreflang: iso + }) + } + } + + if (defaultLocale) { + const localePath = switchLocalePath(defaultLocale) + if (localePath) { + links.push({ + [idAttribute]: 'i18n-xd', + rel: 'alternate2', + href: toAbsoluteUrl(localePath, baseUrl.value), + hreflang: 'x-default' + }) + } + } + + head.link.push(...links) +} + +export function addCanonicalLinksAndOgUrl( + head: HeadParam, + idAttribute: IdParam, + seoAttributesOptions: I18nHeadOptions['addSeoAttributes'] +) { + const { baseUrl } = useI18n() + const route = useRoute() + const localeRoute = useLocaleRoute() + const getRouteBaseName = useRouteBaseName() + const currentRoute = localeRoute({ ...route, name: getRouteBaseName.call(route) }) + + if (!currentRoute) return + let href = toAbsoluteUrl(currentRoute.path, baseUrl.value) + + const canonicalQueries = (isObject(seoAttributesOptions) && seoAttributesOptions.canonicalQueries) || [] + const currentRouteQueryParams = currentRoute.query + const params = new URLSearchParams() + for (const queryParamName of canonicalQueries) { + if (queryParamName in currentRouteQueryParams) { + const queryParamValue = currentRouteQueryParams[queryParamName] + + if (isArray(queryParamValue)) { + queryParamValue.forEach(v => params.append(queryParamName, v || '')) + } else { + params.append(queryParamName, queryParamValue || '') + } + } + } + + const queryString = params.toString() + if (queryString) { + href = `${href}?${queryString}` + } + + head.link.push({ [idAttribute]: 'i18n-can', rel: 'canonical', href }) + head.meta.push({ [idAttribute]: 'i18n-og-url', property: 'og:url', content: href }) +} + +export function addCurrentOgLocale( + currentLocale: LocaleObject, + currentIso: string | undefined, + head: HeadParam, + idAttribute: IdParam +) { + if (!currentLocale || !currentIso) return + + head.meta.push({ + [idAttribute]: 'i18n-og', + property: 'og:locale', + // Replace dash with underscore as defined in spec: language_TERRITORY + content: hypenToUnderscore(currentIso) + }) +} + +export function addAlternateOgLocales( + locales: LocaleObject[], + currentIso: string | undefined, + head: HeadParam, + idAttribute: IdParam +) { + const alternateLocales = locales.filter(locale => locale.iso && locale.iso !== currentIso) + + for (const locale of alternateLocales) { + head.meta.push({ + [idAttribute]: `i18n-og-alt-${locale.iso}`, + property: 'og:locale:alternate', + content: hypenToUnderscore(locale.iso!) + }) + } +} + +function hypenToUnderscore(str: string) { + return (str || '').replace(/-/g, '_') +} + +function toAbsoluteUrl(urlOrPath: string, baseUrl: string) { + if (urlOrPath.match(/^https?:\/\//)) return urlOrPath + return baseUrl + urlOrPath +} + +export function getNormalizedLocales(locales: string[] | LocaleObject[]): LocaleObject[] { + const normalized: LocaleObject[] = [] + for (const locale of locales) { + if (isString(locale)) { + normalized.push({ code: locale }) + continue + } + normalized.push(locale) + } + return normalized +} From 743153d5c8063d9e2f2ee0a8e2ee0af7615f9ff1 Mon Sep 17 00:00:00 2001 From: Bobbie Goede Date: Wed, 29 Nov 2023 11:09:04 +0100 Subject: [PATCH 02/16] test: test `useI18nParams` composable --- specs/basic_usage.spec.ts | 9 +++++ specs/fixtures/basic_usage/app.vue | 6 ++++ .../basic_usage/components/LangSwitcher.vue | 1 + specs/fixtures/basic_usage/pages/products.vue | 31 ++++++++++++++++ .../basic_usage/pages/products/[slug].vue | 36 +++++++++++++++++++ .../basic_usage/server/api/products-data.ts | 35 ++++++++++++++++++ .../basic_usage/server/api/products.ts | 19 ++++++++++ .../server/api/products/[product].ts | 27 ++++++++++++++ 8 files changed, 164 insertions(+) create mode 100644 specs/fixtures/basic_usage/pages/products.vue create mode 100644 specs/fixtures/basic_usage/pages/products/[slug].vue create mode 100644 specs/fixtures/basic_usage/server/api/products-data.ts create mode 100644 specs/fixtures/basic_usage/server/api/products.ts create mode 100644 specs/fixtures/basic_usage/server/api/products/[product].ts diff --git a/specs/basic_usage.spec.ts b/specs/basic_usage.spec.ts index 41cc73023..0bef65b69 100644 --- a/specs/basic_usage.spec.ts +++ b/specs/basic_usage.spec.ts @@ -319,3 +319,12 @@ 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') + console.log(await page.content()) + expect(await page.locator('#switch-nuxt-link-nl').getAttribute('href')).toEqual('/nl/products/grote-stoel') + + await gotoPath(page, '/nl/products/rode-mok') + expect(await page.locator('#switch-nuxt-link-en').getAttribute('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 @@ + +