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.
@@ -66,7 +66,7 @@ function handler() {
66
66
  isMenuShow.value = !isMenuShow.value;
67
67
  }
68
68
 
69
- const enabledLocales = useState('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 = useState('enabledLocales', () => locales.value);
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: '1', foo: 'bar', categories: 'teen' } };
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=1');
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
+ };
@@ -8,4 +8,6 @@ export function useNoindex() {
8
8
  { name: 'robots', content: 'noindex, follow' },
9
9
  ],
10
10
  });
11
+
12
+ useSetSeoOverride(() => ({ noindex: true }));
11
13
  }
@@ -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
- // на единственной странице sort не участвует в URL
25
- if (allowedParams.includes(key) && !(overrides.value.alonePage && key === 'sort')) {
26
- if (Array.isArray(value)) {
27
- value.forEach(v => url.searchParams.append(key, v));
28
- }
29
- else if (value != null) {
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
- const patch = toValue(overrides);
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
- state.value = state.value?.path === route.path
41
- ? { ...state.value, ...patch }
42
- : { path: route.path, ...patch };
43
- });
57
+ state.value = next;
58
+ },
59
+ { immediate: true }
60
+ );
44
61
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.840",
4
+ "version": "0.0.842",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -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
+ });