type I18nMock = { locale: { value: string }; // текущий язык locales: { value: Array }; // список доступных языков }; type LocalePathMock = (path: string, localeCode: string) => string; /** * Генерирует canonical URL и hreflang alternate-ссылки для всех локалей с сохранением допустимых query-параметров. * Уточнения (единственная страница пагинации, подмена локали canonical) приходят через useSeoOverrides. * @param baseDomain - базовый домен (например 'https://example.com') * @param i18n - объект i18n с locale и locales * @param localePath - функция для генерации пути с локалью */ export const useSeoLinks = (baseDomain: string, i18n: I18nMock, localePath: LocalePathMock) => { const route = useRoute(); const overrides = useSeoOverrides(); const enabledCodes = useEnabledLanguageCodes(); const allowedParams: string[] = ['page', 'sort', 'categories']; const isParamAllowed = (key: string, value: string) => { if (!allowedParams.includes(key)) return false; // на единственной странице sort не участвует в URL if (overrides.value.alonePage && key === 'sort') return false; // ?page=1 — тот же контент, что и без параметра, дубля быть не должно if (key === 'page' && value === '1') return false; return true; }; const createUrlWithParams = (path: string) => { const url = new URL(`${baseDomain}${path}`); Object.entries(route.query).forEach(([key, value]) => { const values = Array.isArray(value) ? value : [value]; values.forEach((item) => { if (item == null || !isParamAllowed(key, String(item))) return; url.searchParams.append(key, String(item)); }); }); return url.toString(); }; const langMap: Record = { ja: 'ja-JP', zh: 'zh-CN' }; const mapHreflang = (code: string) => langMap[code] || code; const forcedCanonicalLocale = computed(() => { const { canonicalLocale } = overrides.value; return canonicalLocale && canonicalLocale !== i18n.locale.value ? canonicalLocale : null; }); const canonicalUrl = computed(() => createUrlWithParams( forcedCanonicalLocale.value ? localePath(route.path, forcedCanonicalLocale.value) : route.path )); const alternateLinks = computed(() => { // страница неканонична (нет перевода) — hreflang-кластер объявляет канонический URL, а не эта страница if (forcedCanonicalLocale.value) return []; // закрытой от индексации странице языковой кластер не нужен if (overrides.value.noindex) return []; const links = []; const currentLocaleCode = i18n.locale.value; const currentHreflang = mapHreflang(currentLocaleCode); links.push({ rel: 'alternate', hreflang: currentHreflang, href: createUrlWithParams(localePath(route.path, currentLocaleCode)), key: `alternate-${currentHreflang}-${route.path}`, }); if (currentLocaleCode !== 'en') { links.push({ rel: 'alternate', hreflang: 'en', href: createUrlWithParams(localePath(route.path, 'en')), key: `alternate-en-${route.path}`, }); } // локаль, выключенная на сайте, отдаёт 404 (server/middleware/locale-guard.ts) — // такие hreflang вели бы на несуществующие страницы const isEnabled = (code: string) => !enabledCodes.value.length || enabledCodes.value.includes(code); const otherLocales = i18n.locales.value.filter( (locale) => { const localeCode = typeof locale === 'string' ? locale : locale.code; return localeCode !== currentLocaleCode && localeCode !== 'en' && isEnabled(localeCode); } ); links.push( ...otherLocales.map((locale) => { const localeCode = typeof locale === 'string' ? locale : locale.code; const hreflang = mapHreflang(localeCode); return { rel: 'alternate', hreflang, href: createUrlWithParams(localePath(route.path, localeCode)), key: `alternate-${hreflang}-${route.path}`, }; }) ); links.push({ rel: 'alternate', hreflang: 'x-default', href: createUrlWithParams(localePath(route.path, 'en')), key: `alternate-x-default-${route.path}`, }); return links; }); return { canonicalUrl, alternateLinks, }; };