|
1 | 1 | import { useHead, useSeoMeta } from '@unhead/vue' |
2 | 2 | import { computed } from 'vue' |
3 | 3 | import { useI18n } from 'vue-i18n' |
| 4 | +import { useRoute } from 'vue-router' |
| 5 | +import { i18n, getDomainLocale } from '../i18n' |
4 | 6 |
|
5 | 7 | interface SeoOptions { |
6 | 8 | titleKey?: string |
7 | 9 | descriptionKey?: string |
8 | 10 | title?: string |
9 | 11 | description?: string |
10 | | - path?: string |
11 | 12 | image?: string |
12 | 13 | type?: 'website' | 'article' |
13 | 14 | } |
14 | 15 |
|
15 | 16 | export function useSeo(options: SeoOptions = {}) { |
16 | 17 | const { t, locale } = useI18n() |
| 18 | + const route = useRoute() |
17 | 19 |
|
18 | | - const title = computed(() => options.title || (options.titleKey ? t(options.titleKey) : 'fxTunnel')) |
19 | | - const description = computed(() => options.description || (options.descriptionKey ? t(options.descriptionKey) : t('seo.defaultDescription'))) |
20 | | - const url = computed(() => `https://fxtun.dev${options.path || ''}`) |
| 20 | + // Effective locale: forcedLocale from route meta takes priority |
| 21 | + const effectiveLocale = (route.meta.forcedLocale as 'en' | 'ru') ?? locale.value |
| 22 | + |
| 23 | + // Apply forcedLocale globally (for component body rendering) |
| 24 | + if (route.meta.forcedLocale) { |
| 25 | + locale.value = effectiveLocale |
| 26 | + // @ts-expect-error vue-i18n composition api |
| 27 | + i18n.global.locale.value = effectiveLocale |
| 28 | + } |
| 29 | + |
| 30 | + // Helper: translate with explicit locale (bypasses reactive locale for SSG) |
| 31 | + // @ts-expect-error vue-i18n message schema |
| 32 | + const te = (key: string) => i18n.global.messages.value[effectiveLocale]?.[key.split('.')[0]] |
| 33 | + ? t(key, [], { locale: effectiveLocale }) |
| 34 | + : t(key) |
| 35 | + |
| 36 | + const title = computed(() => options.title || (options.titleKey ? te(options.titleKey) : 'fxTunnel')) |
| 37 | + const description = computed(() => options.description || (options.descriptionKey ? te(options.descriptionKey) : te('seo.defaultDescription'))) |
21 | 38 | const image = options.image || 'https://fxtun.dev/og-image.png' |
22 | 39 |
|
| 40 | + const isLangPrefix = computed(() => |
| 41 | + route.path.startsWith('/ru') || route.path.startsWith('/en') |
| 42 | + ) |
| 43 | + |
| 44 | + const cleanPath = computed(() => { |
| 45 | + if (route.path.startsWith('/ru')) return route.path.slice(3) || '/' |
| 46 | + if (route.path.startsWith('/en')) return route.path.slice(3) || '/' |
| 47 | + return route.path |
| 48 | + }) |
| 49 | + |
| 50 | + const enCanonical = computed(() => `https://fxtun.dev${cleanPath.value}`) |
| 51 | + const ruCanonical = computed(() => `https://fxtun.ru${cleanPath.value}`) |
| 52 | + |
| 53 | + const canonical = computed(() => { |
| 54 | + if (isLangPrefix.value) { |
| 55 | + return route.path.startsWith('/ru') ? ruCanonical.value : enCanonical.value |
| 56 | + } |
| 57 | + const domain = getDomainLocale() === 'ru' ? 'fxtun.ru' : 'fxtun.dev' |
| 58 | + return `https://${domain}${cleanPath.value}` |
| 59 | + }) |
| 60 | + |
| 61 | + const showHreflang = computed(() => !isLangPrefix.value) |
| 62 | + |
| 63 | + useHead({ |
| 64 | + htmlAttrs: { lang: effectiveLocale }, |
| 65 | + link: computed(() => [ |
| 66 | + { rel: 'canonical', href: canonical.value }, |
| 67 | + ...(showHreflang.value ? [ |
| 68 | + { rel: 'alternate', hreflang: 'en', href: enCanonical.value }, |
| 69 | + { rel: 'alternate', hreflang: 'ru', href: ruCanonical.value }, |
| 70 | + { rel: 'alternate', hreflang: 'x-default', href: enCanonical.value }, |
| 71 | + ] : []), |
| 72 | + ]), |
| 73 | + }) |
| 74 | + |
23 | 75 | useSeoMeta({ |
24 | 76 | title, |
25 | 77 | description, |
26 | 78 | ogTitle: title, |
27 | 79 | ogDescription: description, |
28 | 80 | ogImage: image, |
29 | | - ogUrl: url, |
| 81 | + ogUrl: canonical, |
30 | 82 | ogType: options.type || 'website', |
31 | 83 | ogSiteName: 'fxTunnel', |
32 | 84 | twitterCard: 'summary_large_image', |
33 | 85 | twitterTitle: title, |
34 | 86 | twitterDescription: description, |
35 | 87 | twitterImage: image, |
36 | 88 | }) |
37 | | - |
38 | | - useHead({ |
39 | | - htmlAttrs: { lang: locale }, |
40 | | - link: [ |
41 | | - { rel: 'canonical', href: url }, |
42 | | - { rel: 'alternate', hreflang: 'en', href: url }, |
43 | | - { rel: 'alternate', hreflang: 'ru', href: url }, |
44 | | - { rel: 'alternate', hreflang: 'x-default', href: url }, |
45 | | - ], |
46 | | - }) |
47 | 89 | } |
0 commit comments