itube-specs 0.0.840 → 0.0.842
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/layout/footer-language.vue +1 -1
- package/components/layout/language.vue +1 -16
- package/composables/__tests__/use-seo-links.test.ts +44 -2
- package/composables/__tests__/use-seo-overrides.test.ts +101 -0
- package/composables/use-enabled-locales.ts +17 -0
- package/composables/use-noindex.ts +2 -0
- package/composables/use-seo-links.ts +25 -10
- package/composables/use-seo-overrides.ts +23 -6
- package/package.json +1 -1
- package/plugins/enabled-locales.server.ts +20 -0
|
@@ -66,7 +66,7 @@ function handler() {
|
|
|
66
66
|
isMenuShow.value = !isMenuShow.value;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const enabledLocales =
|
|
69
|
+
const enabledLocales = useEnabledLocales();
|
|
70
70
|
|
|
71
71
|
const localeName = computed(() => locales.value.find(item => item.code === locale.value)?.name || 'English');
|
|
72
72
|
|
|
@@ -120,8 +120,6 @@ defineProps<{
|
|
|
120
120
|
}>();
|
|
121
121
|
|
|
122
122
|
const countriesItems = useState<ISelectItem[]>('countriesItems', () => []);
|
|
123
|
-
const enabledLanguageCodesState = useState<string[]>('enabledLanguageCodes', () => []);
|
|
124
|
-
|
|
125
123
|
const clientCountryCode = useState<string>('clientCountryCode', () => '');
|
|
126
124
|
|
|
127
125
|
const clientCountryIcon = computed(() => {
|
|
@@ -146,13 +144,6 @@ const filteredCountries = computed(() => {
|
|
|
146
144
|
return countriesItems.value.filter(item => item.title.toLowerCase().includes(query));
|
|
147
145
|
});
|
|
148
146
|
|
|
149
|
-
if (!enabledLanguageCodesState.value.length) {
|
|
150
|
-
const { data: enabledLanguageCodes } = await useFetch<string[]>('/bff/site/languages', { default: () => [] });
|
|
151
|
-
if (enabledLanguageCodes.value?.length) {
|
|
152
|
-
enabledLanguageCodesState.value = enabledLanguageCodes.value;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
147
|
const isCountryLoading = ref(false);
|
|
157
148
|
|
|
158
149
|
// Список стран не приходит в payload (см. plugins/countries.server.ts) — догружаем
|
|
@@ -178,13 +169,7 @@ function onCountryToggle(e: Event) {
|
|
|
178
169
|
if (isCountryOpen.value) loadCountries();
|
|
179
170
|
}
|
|
180
171
|
|
|
181
|
-
const enabledLocales =
|
|
182
|
-
|
|
183
|
-
watch(enabledLanguageCodesState, (codes) => {
|
|
184
|
-
enabledLocales.value = codes?.length
|
|
185
|
-
? locales.value.filter(item => codes.includes(item.code))
|
|
186
|
-
: locales.value;
|
|
187
|
-
}, { immediate: true });
|
|
172
|
+
const enabledLocales = useEnabledLocales();
|
|
188
173
|
|
|
189
174
|
const selectedLocale = ref(locale.value);
|
|
190
175
|
|
|
@@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|
|
3
3
|
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
|
|
4
4
|
import { useSeoLinks } from '../use-seo-links';
|
|
5
5
|
import { useSeoOverridesState } from '../use-seo-overrides';
|
|
6
|
+
import { useEnabledLanguageCodes } from '../use-enabled-locales';
|
|
6
7
|
|
|
7
8
|
let mockRoute = { path: '/videos', query: {} as Record<string, any> };
|
|
8
9
|
|
|
@@ -23,6 +24,7 @@ describe('useSeoLinks', () => {
|
|
|
23
24
|
mockRoute = { path: '/videos', query: {} };
|
|
24
25
|
mockI18n.locale.value = 'en';
|
|
25
26
|
useSeoOverridesState().value = null;
|
|
27
|
+
useEnabledLanguageCodes().value = [];
|
|
26
28
|
});
|
|
27
29
|
|
|
28
30
|
it('canonical URL', () => {
|
|
@@ -52,6 +54,26 @@ describe('useSeoLinks', () => {
|
|
|
52
54
|
expect(canonicalUrl.value).toContain('sort=popular');
|
|
53
55
|
});
|
|
54
56
|
|
|
57
|
+
it('page=1 не попадает в canonical', () => {
|
|
58
|
+
mockRoute = { path: '/videos', query: { page: '1', sort: 'popular' } };
|
|
59
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
60
|
+
expect(canonicalUrl.value).toBe('https://example.com/videos?sort=popular');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('page > 1 в canonical остаётся', () => {
|
|
64
|
+
mockRoute = { path: '/videos', query: { page: '2' } };
|
|
65
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
66
|
+
expect(canonicalUrl.value).toContain('page=2');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('noindex убирает hreflang-альтернативы', () => {
|
|
70
|
+
mockRoute = { path: '/search/anal/videos', query: {} };
|
|
71
|
+
useSeoOverridesState().value = { path: '/search/anal/videos', noindex: true };
|
|
72
|
+
const { canonicalUrl, alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
73
|
+
expect(alternateLinks.value).toEqual([]);
|
|
74
|
+
expect(canonicalUrl.value).toBe('https://example.com/search/anal/videos');
|
|
75
|
+
});
|
|
76
|
+
|
|
55
77
|
it('alternate links для всех локалей', () => {
|
|
56
78
|
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
57
79
|
const hreflangs = alternateLinks.value.map(l => l.hreflang);
|
|
@@ -63,6 +85,26 @@ describe('useSeoLinks', () => {
|
|
|
63
85
|
expect(hreflangs).toContain('x-default');
|
|
64
86
|
});
|
|
65
87
|
|
|
88
|
+
it('alternate links только для включённых на сайте локалей', () => {
|
|
89
|
+
useEnabledLanguageCodes().value = ['en', 'fr'];
|
|
90
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
91
|
+
const hreflangs = alternateLinks.value.map(l => l.hreflang);
|
|
92
|
+
expect(hreflangs).toContain('en');
|
|
93
|
+
expect(hreflangs).toContain('fr');
|
|
94
|
+
expect(hreflangs).toContain('x-default');
|
|
95
|
+
expect(hreflangs).not.toContain('de');
|
|
96
|
+
expect(hreflangs).not.toContain('ja-JP');
|
|
97
|
+
expect(hreflangs).not.toContain('zh-CN');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('текущая локаль остаётся в alternate, даже если её нет в списке включённых', () => {
|
|
101
|
+
mockRoute = { path: '/ja/videos', query: {} };
|
|
102
|
+
mockI18n.locale.value = 'ja';
|
|
103
|
+
useEnabledLanguageCodes().value = ['en', 'fr'];
|
|
104
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
105
|
+
expect(alternateLinks.value.map(l => l.hreflang)).toContain('ja-JP');
|
|
106
|
+
});
|
|
107
|
+
|
|
66
108
|
it('ja маппится в ja-JP', () => {
|
|
67
109
|
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
68
110
|
const jaLink = alternateLinks.value.find(l => l.hreflang === 'ja-JP');
|
|
@@ -126,9 +168,9 @@ describe('useSeoLinks', () => {
|
|
|
126
168
|
});
|
|
127
169
|
|
|
128
170
|
it('игнорирует неразрешённые query params', () => {
|
|
129
|
-
mockRoute = { path: '/videos', query: { page: '
|
|
171
|
+
mockRoute = { path: '/videos', query: { page: '2', foo: 'bar', categories: 'teen' } };
|
|
130
172
|
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
131
|
-
expect(canonicalUrl.value).toContain('page=
|
|
173
|
+
expect(canonicalUrl.value).toContain('page=2');
|
|
132
174
|
expect(canonicalUrl.value).toContain('categories=teen');
|
|
133
175
|
expect(canonicalUrl.value).not.toContain('foo');
|
|
134
176
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
|
|
4
|
+
import { effectScope, nextTick, reactive, ref, watch } from 'vue';
|
|
5
|
+
import { useSeoOverridesState, useSeoOverrides, useSetSeoOverride } from '../use-seo-overrides';
|
|
6
|
+
|
|
7
|
+
// реактивный, а не переприсваиваемый: useRoute() отдаётся один раз на setup,
|
|
8
|
+
// и смена пути должна долетать через реактивность, как в реальном роутере
|
|
9
|
+
const mockRoute = reactive({ path: '/videos/some-video' });
|
|
10
|
+
|
|
11
|
+
mockNuxtImport('useRoute', () => () => mockRoute);
|
|
12
|
+
|
|
13
|
+
const countWrites = () => {
|
|
14
|
+
const writes = ref(0);
|
|
15
|
+
watch(useSeoOverridesState(), () => { writes.value += 1; }, { flush: 'sync' });
|
|
16
|
+
return writes;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('useSetSeoOverride', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mockRoute.path = '/videos/some-video';
|
|
22
|
+
useSeoOverridesState().value = null;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('пишет уточнение для текущего пути', async () => {
|
|
26
|
+
const scope = effectScope();
|
|
27
|
+
scope.run(() => useSetSeoOverride(() => ({ canonicalLocale: 'en' })));
|
|
28
|
+
await nextTick();
|
|
29
|
+
|
|
30
|
+
expect(useSeoOverridesState().value).toEqual({ path: '/videos/some-video', canonicalLocale: 'en' });
|
|
31
|
+
scope.stop();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('складывает уточнения от разных источников', async () => {
|
|
35
|
+
const scope = effectScope();
|
|
36
|
+
scope.run(() => {
|
|
37
|
+
useSetSeoOverride(() => ({ canonicalLocale: 'en' }));
|
|
38
|
+
useSetSeoOverride(() => ({ alonePage: true }));
|
|
39
|
+
});
|
|
40
|
+
await nextTick();
|
|
41
|
+
|
|
42
|
+
expect(useSeoOverridesState().value).toEqual({
|
|
43
|
+
path: '/videos/some-video',
|
|
44
|
+
canonicalLocale: 'en',
|
|
45
|
+
alonePage: true,
|
|
46
|
+
});
|
|
47
|
+
scope.stop();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// регрессия: watchEffect, читавший и писавший один и тот же стейт, уходил
|
|
51
|
+
// в бесконечный цикл на клиенте («Maximum recursive updates exceeded»)
|
|
52
|
+
it('не зацикливается: конечное число записей в стейт', async () => {
|
|
53
|
+
const writes = countWrites();
|
|
54
|
+
|
|
55
|
+
const scope = effectScope();
|
|
56
|
+
scope.run(() => {
|
|
57
|
+
useSetSeoOverride(() => ({ canonicalLocale: 'en' }));
|
|
58
|
+
useSetSeoOverride(() => ({ alonePage: true }));
|
|
59
|
+
});
|
|
60
|
+
await nextTick();
|
|
61
|
+
await nextTick();
|
|
62
|
+
|
|
63
|
+
expect(writes.value).toBeLessThanOrEqual(2);
|
|
64
|
+
scope.stop();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('повторная запись того же значения стейт не трогает', async () => {
|
|
68
|
+
const scope = effectScope();
|
|
69
|
+
const alonePage = ref(true);
|
|
70
|
+
scope.run(() => useSetSeoOverride(() => ({ alonePage: alonePage.value })));
|
|
71
|
+
await nextTick();
|
|
72
|
+
|
|
73
|
+
const writes = countWrites();
|
|
74
|
+
alonePage.value = true;
|
|
75
|
+
await nextTick();
|
|
76
|
+
|
|
77
|
+
expect(writes.value).toBe(0);
|
|
78
|
+
scope.stop();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('уточнение с другого пути не читается', () => {
|
|
82
|
+
useSeoOverridesState().value = { path: '/other', alonePage: true };
|
|
83
|
+
expect(useSeoOverrides().value).toEqual({});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('смена пути сбрасывает накопленное', async () => {
|
|
87
|
+
const scope = effectScope();
|
|
88
|
+
const canonicalLocale = ref<string | undefined>('en');
|
|
89
|
+
scope.run(() => useSetSeoOverride(() => ({ canonicalLocale: canonicalLocale.value })));
|
|
90
|
+
await nextTick();
|
|
91
|
+
|
|
92
|
+
useSeoOverridesState().value = { ...useSeoOverridesState().value!, alonePage: true };
|
|
93
|
+
|
|
94
|
+
mockRoute.path = '/videos/other-video';
|
|
95
|
+
canonicalLocale.value = undefined;
|
|
96
|
+
await nextTick();
|
|
97
|
+
|
|
98
|
+
expect(useSeoOverridesState().value).toEqual({ path: '/videos/other-video', canonicalLocale: undefined });
|
|
99
|
+
scope.stop();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Коды локалей, реально включённых на сайте. Заполняет plugins/enabled-locales.server.ts,
|
|
3
|
+
* значение уезжает в payload. Пустой список = данных нет, потребители используют все локали.
|
|
4
|
+
*/
|
|
5
|
+
export const useEnabledLanguageCodes = () => useState<string[]>('enabledLanguageCodes', () => []);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Локали i18n, отфильтрованные по включённым на сайте — для переключателей языка и hreflang.
|
|
9
|
+
*/
|
|
10
|
+
export const useEnabledLocales = () => {
|
|
11
|
+
const { locales } = useI18n();
|
|
12
|
+
const codes = useEnabledLanguageCodes();
|
|
13
|
+
|
|
14
|
+
return computed(() => codes.value.length
|
|
15
|
+
? locales.value.filter(item => codes.value.includes(item.code))
|
|
16
|
+
: locales.value);
|
|
17
|
+
};
|
|
@@ -15,21 +15,30 @@ type LocalePathMock = (path: string, localeCode: string) => string;
|
|
|
15
15
|
export const useSeoLinks = (baseDomain: string, i18n: I18nMock, localePath: LocalePathMock) => {
|
|
16
16
|
const route = useRoute();
|
|
17
17
|
const overrides = useSeoOverrides();
|
|
18
|
+
const enabledCodes = useEnabledLanguageCodes();
|
|
18
19
|
|
|
19
20
|
const allowedParams: string[] = ['page', 'sort', 'categories'];
|
|
21
|
+
|
|
22
|
+
const isParamAllowed = (key: string, value: string) => {
|
|
23
|
+
if (!allowedParams.includes(key)) return false;
|
|
24
|
+
// на единственной странице sort не участвует в URL
|
|
25
|
+
if (overrides.value.alonePage && key === 'sort') return false;
|
|
26
|
+
// ?page=1 — тот же контент, что и без параметра, дубля быть не должно
|
|
27
|
+
if (key === 'page' && value === '1') return false;
|
|
28
|
+
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
|
|
20
32
|
const createUrlWithParams = (path: string) => {
|
|
21
33
|
const url = new URL(`${baseDomain}${path}`);
|
|
22
34
|
|
|
23
35
|
Object.entries(route.query).forEach(([key, value]) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
url.searchParams.append(key, String(value));
|
|
31
|
-
}
|
|
32
|
-
}
|
|
36
|
+
const values = Array.isArray(value) ? value : [value];
|
|
37
|
+
|
|
38
|
+
values.forEach((item) => {
|
|
39
|
+
if (item == null || !isParamAllowed(key, String(item))) return;
|
|
40
|
+
url.searchParams.append(key, String(item));
|
|
41
|
+
});
|
|
33
42
|
});
|
|
34
43
|
|
|
35
44
|
return url.toString();
|
|
@@ -55,6 +64,8 @@ export const useSeoLinks = (baseDomain: string, i18n: I18nMock, localePath: Loca
|
|
|
55
64
|
const alternateLinks = computed(() => {
|
|
56
65
|
// страница неканонична (нет перевода) — hreflang-кластер объявляет канонический URL, а не эта страница
|
|
57
66
|
if (forcedCanonicalLocale.value) return [];
|
|
67
|
+
// закрытой от индексации странице языковой кластер не нужен
|
|
68
|
+
if (overrides.value.noindex) return [];
|
|
58
69
|
|
|
59
70
|
const links = [];
|
|
60
71
|
const currentLocaleCode = i18n.locale.value;
|
|
@@ -77,10 +88,14 @@ export const useSeoLinks = (baseDomain: string, i18n: I18nMock, localePath: Loca
|
|
|
77
88
|
});
|
|
78
89
|
}
|
|
79
90
|
|
|
91
|
+
// локаль, выключенная на сайте, отдаёт 404 (server/middleware/locale-guard.ts) —
|
|
92
|
+
// такие hreflang вели бы на несуществующие страницы
|
|
93
|
+
const isEnabled = (code: string) => !enabledCodes.value.length || enabledCodes.value.includes(code);
|
|
94
|
+
|
|
80
95
|
const otherLocales = i18n.locales.value.filter(
|
|
81
96
|
(locale) => {
|
|
82
97
|
const localeCode = typeof locale === 'string' ? locale : locale.code;
|
|
83
|
-
return localeCode !== currentLocaleCode && localeCode !== 'en';
|
|
98
|
+
return localeCode !== currentLocaleCode && localeCode !== 'en' && isEnabled(localeCode);
|
|
84
99
|
}
|
|
85
100
|
);
|
|
86
101
|
|
|
@@ -3,6 +3,8 @@ type SeoOverrides = {
|
|
|
3
3
|
canonicalLocale?: string;
|
|
4
4
|
/** страница единственная в пагинации — из canonical выкидывается sort */
|
|
5
5
|
alonePage?: boolean;
|
|
6
|
+
/** страница закрыта от индексации — hreflang-кластер ей не нужен */
|
|
7
|
+
noindex?: boolean;
|
|
6
8
|
};
|
|
7
9
|
|
|
8
10
|
type SeoOverridesState = SeoOverrides & {
|
|
@@ -26,6 +28,13 @@ export const useSeoOverrides = () => {
|
|
|
26
28
|
return computed<SeoOverrides>(() => state.value?.path === route.path ? state.value : {});
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
const isSameOverride = (a: SeoOverridesState | null, b: SeoOverridesState) =>
|
|
32
|
+
!!a
|
|
33
|
+
&& a.path === b.path
|
|
34
|
+
&& a.canonicalLocale === b.canonicalLocale
|
|
35
|
+
&& a.alonePage === b.alonePage
|
|
36
|
+
&& a.noindex === b.noindex;
|
|
37
|
+
|
|
29
38
|
/**
|
|
30
39
|
* Записывает уточнение для текущего маршрута. Значения от разных источников
|
|
31
40
|
* (страница, пагинация) складываются; при смене маршрута состояние сбрасывается.
|
|
@@ -34,11 +43,19 @@ export const useSetSeoOverride = (overrides: MaybeRefOrGetter<SeoOverrides>) =>
|
|
|
34
43
|
const route = useRoute();
|
|
35
44
|
const state = useSeoOverridesState();
|
|
36
45
|
|
|
37
|
-
watchEffect
|
|
38
|
-
|
|
46
|
+
// именно watch с явными источниками, а не watchEffect: чтение state внутри колбэка
|
|
47
|
+
// не отслеживается, иначе запись сюда же перезапускала бы эффект по кругу
|
|
48
|
+
watch(
|
|
49
|
+
[() => route.path, () => toValue(overrides)],
|
|
50
|
+
([path, patch]) => {
|
|
51
|
+
const next: SeoOverridesState = state.value?.path === path
|
|
52
|
+
? { ...state.value, ...patch }
|
|
53
|
+
: { path, ...patch };
|
|
54
|
+
|
|
55
|
+
if (isSameOverride(state.value, next)) return;
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
state.value = next;
|
|
58
|
+
},
|
|
59
|
+
{ immediate: true }
|
|
60
|
+
);
|
|
44
61
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SSR-плагин: кладёт коды реально включённых на сайте локалей в useState('enabledLanguageCodes').
|
|
2
|
+
// Нужен, чтобы hreflang в app.vue и переключатели языков не предлагали локали, на которые
|
|
3
|
+
// server/middleware/locale-guard.ts отдаёт 404. Раньше список тянул только layout/language.vue,
|
|
4
|
+
// а он рендерится лениво по клику — на SSR не выполнялся никогда.
|
|
5
|
+
// Эндпоинт читает статический SiteDataService и сам падает обратно на runtimeConfig.public.locales.
|
|
6
|
+
export default defineNuxtPlugin({
|
|
7
|
+
parallel: true,
|
|
8
|
+
async setup() {
|
|
9
|
+
const codes = useEnabledLanguageCodes();
|
|
10
|
+
if (codes.value.length) return;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const languages = await $fetch<string[]>('/api/site/languages');
|
|
14
|
+
if (languages?.length) codes.value = languages;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// оставляем пустым — потребители падают обратно на полный список локалей
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
});
|