From 6752118229e0530f53c55ba14d615ea9c7de0a31 Mon Sep 17 00:00:00 2001 From: Bobbie Goede Date: Wed, 29 Nov 2023 11:08:32 +0100 Subject: [PATCH] feat: add `useSetI18nParams` composable --- package.json | 2 + pnpm-lock.yaml | 52 ++++++++--- src/runtime/composables.ts | 72 ++++++++++++++- src/runtime/plugins/i18n.ts | 14 ++- src/runtime/utils.ts | 170 ++++++++++++++++++++++++++++++++++-- 5 files changed, 288 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 59f34d776..a9068f814 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,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", @@ -124,6 +125,7 @@ "typescript": "^5.2.2", "unbuild": "^2.0.0", "undici": "^5.27.2", + "unhead": "^1.8.8", "vitest": "^0.34.6", "vue": "^3.3.4", "vue-router": "^4.2.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17498d0d2..6aa9b9aed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@typescript-eslint/parser': specifier: ^6.7.4 version: 6.7.4(eslint@8.50.0)(typescript@5.2.2) + '@unhead/vue': + specifier: ^1.8.8 + version: 1.8.8(vue@3.3.4) bumpp: specifier: ^9.2.0 version: 9.2.0 @@ -152,6 +155,9 @@ importers: undici: specifier: ^5.27.2 version: 5.27.2 + unhead: + specifier: ^1.8.8 + version: 1.8.8 vitest: specifier: ^0.34.6 version: 0.34.6(jsdom@21.1.2)(playwright@1.38.1) @@ -3049,6 +3055,13 @@ packages: '@unhead/shared': 1.3.7 dev: true + /@unhead/dom@1.8.8: + resolution: {integrity: sha512-KRtn+tvA83lEtKrtZD85XmqW04fcytVuNKLUpPBzhJvsxB3v7gozw0nu46e3EpbO3TGJjLlLd6brNHQY6WLWfA==} + dependencies: + '@unhead/schema': 1.8.8 + '@unhead/shared': 1.8.8 + dev: true + /@unhead/schema@1.3.7: resolution: {integrity: sha512-C0+wA2ZZl4d2Aj0z3mFoDKDTv/22z0Tu5giXj+T+iEmfAir9k6kH2UrrCDMkHUP/mRnBSEg1URBrFq2al34VKg==} dependencies: @@ -3056,12 +3069,25 @@ packages: zhead: 2.0.10 dev: true + /@unhead/schema@1.8.8: + resolution: {integrity: sha512-xuhNW4osVNLW1yQSbdInZ8YGiXVTi1gjF8rK1E4VnODpWLg8XOq0OpoCbdIlCH4X4A0Ee0UQGRyzkuuVZlrSsQ==} + dependencies: + hookable: 5.5.3 + zhead: 2.2.4 + dev: true + /@unhead/shared@1.3.7: resolution: {integrity: sha512-73bs2B5wCMCr+X81qbEVPwFd/7pN8SXSgsSSwq9KkhmB+hC3bipiDST+Fe1h7F80lZ4iu9EwjrNxNlXw+tLjsw==} dependencies: '@unhead/schema': 1.3.7 dev: true + /@unhead/shared@1.8.8: + resolution: {integrity: sha512-LoIJUDgmOzxoRHSIf29w/wc+IzKN2XvGiQC2dZZrYoTjOOzodf75609PEW5bhx2aHio38k9F+6BnD3KDiJ7IIg==} + dependencies: + '@unhead/schema': 1.8.8 + dev: true + /@unhead/ssr@1.3.7: resolution: {integrity: sha512-6FNA2h4AA3I52YQUJ7JqAi0JmixFTa/hM9UWoLDGu9FpFJKiQfRX4s1bm8RPaLC+HTR/GhGdUcwkT4gxU54SLg==} dependencies: @@ -3069,15 +3095,15 @@ packages: '@unhead/shared': 1.3.7 dev: true - /@unhead/vue@1.3.7(vue@3.3.4): - resolution: {integrity: sha512-ekvE592mAJxwoscCt/6Z2gwXHb4IzWIUsy/vcBXd/aEo0bOPww9qObCyS3/GxhknRdItDhJOwfO9CId+bSRG8Q==} + /@unhead/vue@1.8.8(vue@3.3.4): + resolution: {integrity: sha512-isHpVnSSE5SP+ObsZG/i+Jq9tAQ2u1AbGrktXKmL7P5FRxwPjhATYnJFdGpxXeXfuaFgRFKzGKs29xo4MMVODw==} peerDependencies: vue: '>=2.7 || >=3' dependencies: - '@unhead/schema': 1.3.7 - '@unhead/shared': 1.3.7 + '@unhead/schema': 1.8.8 + '@unhead/shared': 1.8.8 hookable: 5.5.3 - unhead: 1.3.7 + unhead: 1.8.8 vue: 3.3.4 dev: true @@ -8015,7 +8041,7 @@ packages: '@nuxt/vite-builder': 3.7.0(eslint@8.50.0)(rollup@3.28.1)(typescript@5.2.2)(vue@3.3.4) '@unhead/dom': 1.3.7 '@unhead/ssr': 1.3.7 - '@unhead/vue': 1.3.7(vue@3.3.4) + '@unhead/vue': 1.8.8(vue@3.3.4) '@vue/shared': 3.3.4 acorn: 8.10.0 c12: 1.4.2 @@ -10363,12 +10389,12 @@ packages: pathe: 1.1.1 dev: true - /unhead@1.3.7: - resolution: {integrity: sha512-XRkDIaIK325UyKwSqV6fDbFKJ4HYuT5mCEnIhUqNBtUYv6b7jdXzYTfUiZSb1ciJyTqvzRHFWDtmGtJo1L375Q==} + /unhead@1.8.8: + resolution: {integrity: sha512-SfUJ2kjz1NcfvdM+uEAlN11h31wHqMg0HZ5jriuRPjMCj5O7lPs4uSMdBUYh3KEo0uLKrW76FM85ONXkyZfm3g==} dependencies: - '@unhead/dom': 1.3.7 - '@unhead/schema': 1.3.7 - '@unhead/shared': 1.3.7 + '@unhead/dom': 1.8.8 + '@unhead/schema': 1.8.8 + '@unhead/shared': 1.8.8 hookable: 5.5.3 dev: true @@ -11461,6 +11487,10 @@ packages: resolution: {integrity: sha512-irug8fXNKjqazkA27cFQs7C6/ZD3qNiEzLC56kDyzQART/Z9GMGfg8h2i6fb9c8ZWnIx/QgOgFJxK3A/CYHG0g==} dev: true + /zhead@2.2.4: + resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==} + dev: true + /zip-stream@4.1.0: resolution: {integrity: sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A==} engines: {node: '>= 10'} diff --git a/src/runtime/composables.ts b/src/runtime/composables.ts index 778754c14..11b3d3487 100644 --- a/src/runtime/composables.ts +++ b/src/runtime/composables.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, @@ -16,8 +19,71 @@ import type { Ref } from 'vue' import type { DetectBrowserLanguageOptions } from '#build/i18n.options.mjs' export * from 'vue-i18n' -export type { LocaleObject } from 'vue-i18n-routing' -import type { Locale, LocaleMessages, DefineLocaleMessage, I18nOptions } 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 629a75157..d939dad3c 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 { deepCopy } from '@intlify/shared' import { @@ -14,7 +14,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, @@ -107,7 +114,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 d9a2fb8b5..528567e6e 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,9 +13,12 @@ import { DefaultPrefixable, DefaultSwitchLocalePathIntercepter, getComposer, - useSwitchLocalePath + useLocaleRoute, + useRouteBaseName, + useSwitchLocalePath, + STRATEGIES } from 'vue-i18n-routing' -import { navigateTo, useState } from '#imports' +import { navigateTo, useRoute, useState } from '#imports' import { isString, isFunction, isArray, isObject, deepCopy } from '@intlify/shared' import { nuxtI18nInternalOptions, nuxtI18nOptionsDefault, NUXT_I18N_MODULE_ID, isSSG } from '#build/i18n.options.mjs' import { @@ -39,13 +42,22 @@ import type { RouteLocationNormalizedLoaded, BaseUrlResolveHandler, PrefixableOptions, - SwitchLocalePathIntercepter + SwitchLocalePathIntercepter, + I18nHeadOptions } from 'vue-i18n-routing' import type { NuxtApp } from '#app' -import type { I18n, I18nOptions, Locale, FallbackLocale, LocaleMessages, DefineLocaleMessage } from 'vue-i18n' +import { + type I18n, + type I18nOptions, + type Locale, + type FallbackLocale, + type LocaleMessages, + type DefineLocaleMessage +} from 'vue-i18n' import type { NuxtI18nOptions, DetectBrowserLanguageOptions, RootRedirectOptions } from '#build/i18n.options.mjs' import type { DetectLocaleContext } from './internal' import type { DeepRequired } from 'ts-essentials' +import type { HeadSafe } from '@unhead/vue' export function _setLocale(i18n: I18n, locale: Locale) { return callVueI18nInterfaces(i18n, 'setLocale', locale) @@ -533,3 +545,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 +}