itube-specs 0.0.841 → 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.
@@ -54,6 +54,26 @@ describe('useSeoLinks', () => {
54
54
  expect(canonicalUrl.value).toContain('sort=popular');
55
55
  });
56
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
+
57
77
  it('alternate links для всех локалей', () => {
58
78
  const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
59
79
  const hreflangs = alternateLinks.value.map(l => l.hreflang);
@@ -148,9 +168,9 @@ describe('useSeoLinks', () => {
148
168
  });
149
169
 
150
170
  it('игнорирует неразрешённые query params', () => {
151
- mockRoute = { path: '/videos', query: { page: '1', foo: 'bar', categories: 'teen' } };
171
+ mockRoute = { path: '/videos', query: { page: '2', foo: 'bar', categories: 'teen' } };
152
172
  const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
153
- expect(canonicalUrl.value).toContain('page=1');
173
+ expect(canonicalUrl.value).toContain('page=2');
154
174
  expect(canonicalUrl.value).toContain('categories=teen');
155
175
  expect(canonicalUrl.value).not.toContain('foo');
156
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
+ });
@@ -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
  }
@@ -18,19 +18,27 @@ export const useSeoLinks = (baseDomain: string, i18n: I18nMock, localePath: Loca
18
18
  const enabledCodes = useEnabledLanguageCodes();
19
19
 
20
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
+
21
32
  const createUrlWithParams = (path: string) => {
22
33
  const url = new URL(`${baseDomain}${path}`);
23
34
 
24
35
  Object.entries(route.query).forEach(([key, value]) => {
25
- // на единственной странице sort не участвует в URL
26
- if (allowedParams.includes(key) && !(overrides.value.alonePage && key === 'sort')) {
27
- if (Array.isArray(value)) {
28
- value.forEach(v => url.searchParams.append(key, v));
29
- }
30
- else if (value != null) {
31
- url.searchParams.append(key, String(value));
32
- }
33
- }
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
+ });
34
42
  });
35
43
 
36
44
  return url.toString();
@@ -56,6 +64,8 @@ export const useSeoLinks = (baseDomain: string, i18n: I18nMock, localePath: Loca
56
64
  const alternateLinks = computed(() => {
57
65
  // страница неканонична (нет перевода) — hreflang-кластер объявляет канонический URL, а не эта страница
58
66
  if (forcedCanonicalLocale.value) return [];
67
+ // закрытой от индексации странице языковой кластер не нужен
68
+ if (overrides.value.noindex) return [];
59
69
 
60
70
  const links = [];
61
71
  const currentLocaleCode = i18n.locale.value;
@@ -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.841",
4
+ "version": "0.0.842",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {