itube-specs 0.0.838 → 0.0.841
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/assets/scss/base/_global.scss +5 -1
- package/components/card/category.vue +48 -32
- package/components/card/channel.vue +2 -1
- package/components/card/model.vue +2 -1
- package/components/card/playlist.vue +2 -1
- package/components/card/round.vue +2 -2
- package/components/card/video/index.vue +1 -0
- package/components/layout/footer-language.vue +1 -1
- package/components/layout/language.vue +1 -16
- package/components/ui/pagination.vue +2 -12
- package/composables/__tests__/use-seo-links.test.ts +92 -9
- package/composables/use-enabled-locales.ts +17 -0
- package/composables/use-seo-links.ts +23 -6
- package/composables/use-seo-overrides.ts +44 -0
- package/package.json +1 -1
- package/pages/categories/all.vue +5 -1
- package/pages/categories/index.vue +5 -1
- package/pages/models/index.vue +7 -1
- package/pages/videos/[slug].vue +10 -1
- package/plugins/enabled-locales.server.ts +20 -0
- package/runtime/utils/cleaners/clean-video-data.ts +1 -0
- package/types/raw/raw-video-data.d.ts +1 -1
- package/types/video-data.d.ts +2 -1
|
@@ -28,12 +28,16 @@ html {
|
|
|
28
28
|
|
|
29
29
|
body {
|
|
30
30
|
font-family: $default-font-family, system-ui, sans-serif;
|
|
31
|
-
background-color: $body-bg;
|
|
32
31
|
color: $body-text;
|
|
32
|
+
background-color: $body-bg;
|
|
33
33
|
-webkit-font-smoothing: antialiased;
|
|
34
34
|
-moz-osx-font-smoothing: grayscale;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
main {
|
|
38
|
+
background-color: $main-bg;
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
a {
|
|
38
42
|
color: $link-text;
|
|
39
43
|
text-decoration: none;
|
|
@@ -5,38 +5,40 @@
|
|
|
5
5
|
:to="localePath(`/categories/${convertString().toSlug(slug)}`)"
|
|
6
6
|
class="card-category"
|
|
7
7
|
>
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<div class="card-category__wrapper
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
8
|
+
<div class="card-category__inner">
|
|
9
|
+
<UiImg
|
|
10
|
+
:alt="card.title"
|
|
11
|
+
:src="currentImage"
|
|
12
|
+
:loading="priority ? 'eager' : 'lazy'"
|
|
13
|
+
:priority="priority"
|
|
14
|
+
class="card-category__img"
|
|
15
|
+
sizes="320px"
|
|
16
|
+
width="320"
|
|
17
|
+
height="180"
|
|
18
|
+
@error="onImageError"
|
|
19
|
+
/>
|
|
20
|
+
<div class="card-category__wrapper">
|
|
21
|
+
<div class="card-category__wrapper-header">
|
|
22
|
+
<span class="card-category__title">
|
|
23
|
+
{{ card.title }}
|
|
24
|
+
<img
|
|
25
|
+
v-if="card.icon"
|
|
26
|
+
:src="`/icons/flags/${card.icon}.svg`"
|
|
27
|
+
:alt="card.icon"
|
|
28
|
+
class="card-category__icon"
|
|
29
|
+
width="17"
|
|
30
|
+
height="17"
|
|
31
|
+
loading="lazy"
|
|
32
|
+
>
|
|
33
|
+
</span>
|
|
34
|
+
<span class="card-category__count">
|
|
35
|
+
<UiIcon
|
|
36
|
+
name="video-camera"
|
|
37
|
+
size="14"
|
|
38
|
+
/>
|
|
39
|
+
{{ $n(card.videosCount, 'compact') }}
|
|
40
|
+
</span>
|
|
41
|
+
</div>
|
|
40
42
|
</div>
|
|
41
43
|
</div>
|
|
42
44
|
</NuxtLink>
|
|
@@ -73,6 +75,19 @@ const slug = computed(() => {
|
|
|
73
75
|
|
|
74
76
|
<style lang="scss">
|
|
75
77
|
.card-category {
|
|
78
|
+
padding: 2px;
|
|
79
|
+
border-radius: $radius-xl;
|
|
80
|
+
|
|
81
|
+
@include from-br(sm) {
|
|
82
|
+
transition: box-shadow var(--transition);
|
|
83
|
+
|
|
84
|
+
&:is(:hover, :focus-visible) {
|
|
85
|
+
box-shadow: $card-ring-shadow--hover;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.card-category__inner {
|
|
76
91
|
border-radius: $radius;
|
|
77
92
|
border: 1px solid $category-card-border;
|
|
78
93
|
background-color: $category-card-bg;
|
|
@@ -93,6 +108,7 @@ const slug = computed(() => {
|
|
|
93
108
|
overflow: hidden;
|
|
94
109
|
white-space: nowrap;
|
|
95
110
|
text-overflow: ellipsis;
|
|
111
|
+
color: $category-card-title;
|
|
96
112
|
|
|
97
113
|
@include from-br(sm) {
|
|
98
114
|
transition: var(--transition) color;
|
|
@@ -116,8 +116,9 @@ const infoItems = computed<InfoItem[]>(() => [
|
|
|
116
116
|
border: 1px solid $channel-card-border;
|
|
117
117
|
background-color: $channel-card-bg;
|
|
118
118
|
|
|
119
|
-
@include hover(border-color) {
|
|
119
|
+
@include hover(border-color, box-shadow) {
|
|
120
120
|
border-color: $channel-card-border--hover;
|
|
121
|
+
box-shadow: $card-ring-shadow--hover;
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
@@ -118,9 +118,10 @@ function getValue(item: { title?: string; value?: string }) {
|
|
|
118
118
|
border: 1px solid $model-card-border;
|
|
119
119
|
display: block;
|
|
120
120
|
|
|
121
|
-
@include hover(border-color, background-color) {
|
|
121
|
+
@include hover(border-color, background-color, box-shadow) {
|
|
122
122
|
border-color: $model-card-border--hover;
|
|
123
123
|
background-color: $model-card-bg--hover;
|
|
124
|
+
box-shadow: $card-ring-shadow--hover;
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
|
|
@@ -231,8 +231,9 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
|
|
|
231
231
|
background-color: $playlist-card-bg;
|
|
232
232
|
border: 1px solid $playlist-card-border;
|
|
233
233
|
|
|
234
|
-
@include hover(border-color) {
|
|
234
|
+
@include hover(border-color, box-shadow) {
|
|
235
235
|
border-color: $playlist-card-border--hover;
|
|
236
|
+
box-shadow: $card-ring-shadow--hover;
|
|
236
237
|
}
|
|
237
238
|
}
|
|
238
239
|
|
|
@@ -77,14 +77,14 @@ function onImageError() {
|
|
|
77
77
|
border: 2px solid $card-round-border;
|
|
78
78
|
|
|
79
79
|
@include from-br(sm) {
|
|
80
|
-
transition: var(--transition) border-color;
|
|
80
|
+
transition: var(--transition) border-color, var(--transition) box-shadow;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
.card-round:is(:hover, :focus-visible) .card-round__img {
|
|
85
85
|
@include from-br(sm) {
|
|
86
86
|
border-color: $card-round-border--hover;
|
|
87
|
-
|
|
87
|
+
box-shadow: $card-ring-shadow--hover;
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -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
|
|
|
@@ -126,7 +126,6 @@
|
|
|
126
126
|
</template>
|
|
127
127
|
|
|
128
128
|
<script setup lang="ts">
|
|
129
|
-
// не забыват проставлять key на компоненте для переинициализации и отслеживания props.total чтобы пересчитывалось правильное сео
|
|
130
129
|
import type { ButtonThemes } from '../../types';
|
|
131
130
|
import { getPaginationNumbers } from '../../utils/ui-pagination/get-pagination-numbers';
|
|
132
131
|
import { getPaginationLink } from '../../utils/ui-pagination/get-pagination-link';
|
|
@@ -180,17 +179,8 @@ watch(() => route.query['page'], () => {
|
|
|
180
179
|
document.documentElement.querySelector('#anchor')?.scrollIntoView({ behavior: 'smooth' });
|
|
181
180
|
});
|
|
182
181
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const siteUrl = useAppConfig().siteUrl as string;
|
|
186
|
-
const { canonicalUrl, alternateLinks } = useSeoLinks(siteUrl, props.total === 1, i18n, localePath);
|
|
187
|
-
|
|
188
|
-
useHead({
|
|
189
|
-
link: computed(() => [
|
|
190
|
-
{ rel: 'canonical', href: canonicalUrl.value, key: `canonical-${route.path}` },
|
|
191
|
-
...alternateLinks.value,
|
|
192
|
-
]),
|
|
193
|
-
});
|
|
182
|
+
// canonical/hreflang выпускает app.vue — отсюда уходит только факт, который знает лишь пагинация
|
|
183
|
+
useSetSeoOverride(() => ({ alonePage: props.total === 1 }));
|
|
194
184
|
</script>
|
|
195
185
|
|
|
196
186
|
<style lang="scss">
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
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
|
+
import { useSeoOverridesState } from '../use-seo-overrides';
|
|
6
|
+
import { useEnabledLanguageCodes } from '../use-enabled-locales';
|
|
5
7
|
|
|
6
8
|
let mockRoute = { path: '/videos', query: {} as Record<string, any> };
|
|
7
9
|
|
|
@@ -12,36 +14,48 @@ const mockI18n = {
|
|
|
12
14
|
locales: { value: ['en', 'fr', 'de', 'ja', 'zh'] },
|
|
13
15
|
};
|
|
14
16
|
|
|
15
|
-
const mockLocalePath = (path: string, locale: string) =>
|
|
16
|
-
|
|
17
|
+
const mockLocalePath = (path: string, locale: string) => {
|
|
18
|
+
const bare = path.replace(/^\/(?:it|fr|es|ja|de|ko|ru|zh)(?=\/|$)/, '');
|
|
19
|
+
return locale === 'en' ? bare : `/${locale}${bare}`;
|
|
20
|
+
};
|
|
17
21
|
|
|
18
22
|
describe('useSeoLinks', () => {
|
|
19
23
|
beforeEach(() => {
|
|
20
24
|
mockRoute = { path: '/videos', query: {} };
|
|
21
25
|
mockI18n.locale.value = 'en';
|
|
26
|
+
useSeoOverridesState().value = null;
|
|
27
|
+
useEnabledLanguageCodes().value = [];
|
|
22
28
|
});
|
|
23
29
|
|
|
24
30
|
it('canonical URL', () => {
|
|
25
|
-
const { canonicalUrl } = useSeoLinks('https://example.com',
|
|
31
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
26
32
|
expect(canonicalUrl.value).toBe('https://example.com/videos');
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
it('canonical с query params', () => {
|
|
30
36
|
mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
|
|
31
|
-
const { canonicalUrl } = useSeoLinks('https://example.com',
|
|
37
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
32
38
|
expect(canonicalUrl.value).toContain('page=2');
|
|
33
39
|
expect(canonicalUrl.value).toContain('sort=popular');
|
|
34
40
|
});
|
|
35
41
|
|
|
36
42
|
it('исключает sort при alonePage', () => {
|
|
37
43
|
mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
|
|
38
|
-
|
|
44
|
+
useSeoOverridesState().value = { path: '/videos', alonePage: true };
|
|
45
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
39
46
|
expect(canonicalUrl.value).toContain('page=2');
|
|
40
47
|
expect(canonicalUrl.value).not.toContain('sort');
|
|
41
48
|
});
|
|
42
49
|
|
|
50
|
+
it('alonePage с чужого пути не применяется', () => {
|
|
51
|
+
mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
|
|
52
|
+
useSeoOverridesState().value = { path: '/models', alonePage: true };
|
|
53
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
54
|
+
expect(canonicalUrl.value).toContain('sort=popular');
|
|
55
|
+
});
|
|
56
|
+
|
|
43
57
|
it('alternate links для всех локалей', () => {
|
|
44
|
-
const { alternateLinks } = useSeoLinks('https://example.com',
|
|
58
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
45
59
|
const hreflangs = alternateLinks.value.map(l => l.hreflang);
|
|
46
60
|
expect(hreflangs).toContain('en');
|
|
47
61
|
expect(hreflangs).toContain('fr');
|
|
@@ -51,22 +65,91 @@ describe('useSeoLinks', () => {
|
|
|
51
65
|
expect(hreflangs).toContain('x-default');
|
|
52
66
|
});
|
|
53
67
|
|
|
68
|
+
it('alternate links только для включённых на сайте локалей', () => {
|
|
69
|
+
useEnabledLanguageCodes().value = ['en', 'fr'];
|
|
70
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
71
|
+
const hreflangs = alternateLinks.value.map(l => l.hreflang);
|
|
72
|
+
expect(hreflangs).toContain('en');
|
|
73
|
+
expect(hreflangs).toContain('fr');
|
|
74
|
+
expect(hreflangs).toContain('x-default');
|
|
75
|
+
expect(hreflangs).not.toContain('de');
|
|
76
|
+
expect(hreflangs).not.toContain('ja-JP');
|
|
77
|
+
expect(hreflangs).not.toContain('zh-CN');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('текущая локаль остаётся в alternate, даже если её нет в списке включённых', () => {
|
|
81
|
+
mockRoute = { path: '/ja/videos', query: {} };
|
|
82
|
+
mockI18n.locale.value = 'ja';
|
|
83
|
+
useEnabledLanguageCodes().value = ['en', 'fr'];
|
|
84
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
85
|
+
expect(alternateLinks.value.map(l => l.hreflang)).toContain('ja-JP');
|
|
86
|
+
});
|
|
87
|
+
|
|
54
88
|
it('ja маппится в ja-JP', () => {
|
|
55
|
-
const { alternateLinks } = useSeoLinks('https://example.com',
|
|
89
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
56
90
|
const jaLink = alternateLinks.value.find(l => l.hreflang === 'ja-JP');
|
|
57
91
|
expect(jaLink).toBeDefined();
|
|
58
92
|
expect(jaLink!.href).toContain('/ja/videos');
|
|
59
93
|
});
|
|
60
94
|
|
|
61
95
|
it('x-default указывает на en', () => {
|
|
62
|
-
const { alternateLinks } = useSeoLinks('https://example.com',
|
|
96
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
63
97
|
const xDefault = alternateLinks.value.find(l => l.hreflang === 'x-default');
|
|
64
98
|
expect(xDefault!.href).toBe('https://example.com/videos');
|
|
65
99
|
});
|
|
66
100
|
|
|
101
|
+
it('canonicalLocale переводит canonical на указанную локаль', () => {
|
|
102
|
+
mockRoute = { path: '/ja/videos/some-video', query: {} };
|
|
103
|
+
mockI18n.locale.value = 'ja';
|
|
104
|
+
useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
|
|
105
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
106
|
+
expect(canonicalUrl.value).toBe('https://example.com/videos/some-video');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('canonicalLocale сохраняет допустимые query params', () => {
|
|
110
|
+
mockRoute = { path: '/ja/videos/some-video', query: { page: '2', foo: 'bar' } };
|
|
111
|
+
mockI18n.locale.value = 'ja';
|
|
112
|
+
useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
|
|
113
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
114
|
+
expect(canonicalUrl.value).toContain('/videos/some-video');
|
|
115
|
+
expect(canonicalUrl.value).not.toContain('/ja/');
|
|
116
|
+
expect(canonicalUrl.value).toContain('page=2');
|
|
117
|
+
expect(canonicalUrl.value).not.toContain('foo');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('canonicalLocale убирает hreflang-альтернативы', () => {
|
|
121
|
+
mockRoute = { path: '/ja/videos/some-video', query: {} };
|
|
122
|
+
mockI18n.locale.value = 'ja';
|
|
123
|
+
useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
|
|
124
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
125
|
+
expect(alternateLinks.value).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('canonicalLocale игнорируется для чужого пути', () => {
|
|
129
|
+
mockRoute = { path: '/ja/videos/other-video', query: {} };
|
|
130
|
+
mockI18n.locale.value = 'ja';
|
|
131
|
+
useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
|
|
132
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
133
|
+
expect(canonicalUrl.value).toBe('https://example.com/ja/videos/other-video');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('canonicalLocale игнорируется, если совпадает с текущей локалью', () => {
|
|
137
|
+
mockRoute = { path: '/videos/some-video', query: {} };
|
|
138
|
+
useSeoOverridesState().value = { path: '/videos/some-video', canonicalLocale: 'en' };
|
|
139
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
140
|
+
expect(canonicalUrl.value).toBe('https://example.com/videos/some-video');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('без override canonical остаётся на текущей локали', () => {
|
|
144
|
+
mockRoute = { path: '/ja/videos/some-video', query: {} };
|
|
145
|
+
mockI18n.locale.value = 'ja';
|
|
146
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
147
|
+
expect(canonicalUrl.value).toBe('https://example.com/ja/videos/some-video');
|
|
148
|
+
});
|
|
149
|
+
|
|
67
150
|
it('игнорирует неразрешённые query params', () => {
|
|
68
151
|
mockRoute = { path: '/videos', query: { page: '1', foo: 'bar', categories: 'teen' } };
|
|
69
|
-
const { canonicalUrl } = useSeoLinks('https://example.com',
|
|
152
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
70
153
|
expect(canonicalUrl.value).toContain('page=1');
|
|
71
154
|
expect(canonicalUrl.value).toContain('categories=teen');
|
|
72
155
|
expect(canonicalUrl.value).not.toContain('foo');
|
|
@@ -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
|
+
};
|
|
@@ -7,21 +7,23 @@ type LocalePathMock = (path: string, localeCode: string) => string;
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Генерирует canonical URL и hreflang alternate-ссылки для всех локалей с сохранением допустимых query-параметров.
|
|
10
|
+
* Уточнения (единственная страница пагинации, подмена локали canonical) приходят через useSeoOverrides.
|
|
10
11
|
* @param baseDomain - базовый домен (например 'https://example.com')
|
|
11
|
-
* @param alonePage - если true, параметр sort исключается из URL
|
|
12
12
|
* @param i18n - объект i18n с locale и locales
|
|
13
13
|
* @param localePath - функция для генерации пути с локалью
|
|
14
14
|
*/
|
|
15
|
-
export const useSeoLinks = (baseDomain: string,
|
|
15
|
+
export const useSeoLinks = (baseDomain: string, i18n: I18nMock, localePath: LocalePathMock) => {
|
|
16
16
|
const route = useRoute();
|
|
17
|
+
const overrides = useSeoOverrides();
|
|
18
|
+
const enabledCodes = useEnabledLanguageCodes();
|
|
17
19
|
|
|
18
20
|
const allowedParams: string[] = ['page', 'sort', 'categories'];
|
|
19
21
|
const createUrlWithParams = (path: string) => {
|
|
20
22
|
const url = new URL(`${baseDomain}${path}`);
|
|
21
23
|
|
|
22
24
|
Object.entries(route.query).forEach(([key, value]) => {
|
|
23
|
-
//
|
|
24
|
-
if (allowedParams.includes(key) && !(alonePage && key === 'sort')) {
|
|
25
|
+
// на единственной странице sort не участвует в URL
|
|
26
|
+
if (allowedParams.includes(key) && !(overrides.value.alonePage && key === 'sort')) {
|
|
25
27
|
if (Array.isArray(value)) {
|
|
26
28
|
value.forEach(v => url.searchParams.append(key, v));
|
|
27
29
|
}
|
|
@@ -41,9 +43,20 @@ export const useSeoLinks = (baseDomain: string, alonePage: boolean = false, i18n
|
|
|
41
43
|
|
|
42
44
|
const mapHreflang = (code: string) => langMap[code] || code;
|
|
43
45
|
|
|
44
|
-
const
|
|
46
|
+
const forcedCanonicalLocale = computed(() => {
|
|
47
|
+
const { canonicalLocale } = overrides.value;
|
|
48
|
+
|
|
49
|
+
return canonicalLocale && canonicalLocale !== i18n.locale.value ? canonicalLocale : null;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const canonicalUrl = computed(() => createUrlWithParams(
|
|
53
|
+
forcedCanonicalLocale.value ? localePath(route.path, forcedCanonicalLocale.value) : route.path
|
|
54
|
+
));
|
|
45
55
|
|
|
46
56
|
const alternateLinks = computed(() => {
|
|
57
|
+
// страница неканонична (нет перевода) — hreflang-кластер объявляет канонический URL, а не эта страница
|
|
58
|
+
if (forcedCanonicalLocale.value) return [];
|
|
59
|
+
|
|
47
60
|
const links = [];
|
|
48
61
|
const currentLocaleCode = i18n.locale.value;
|
|
49
62
|
|
|
@@ -65,10 +78,14 @@ export const useSeoLinks = (baseDomain: string, alonePage: boolean = false, i18n
|
|
|
65
78
|
});
|
|
66
79
|
}
|
|
67
80
|
|
|
81
|
+
// локаль, выключенная на сайте, отдаёт 404 (server/middleware/locale-guard.ts) —
|
|
82
|
+
// такие hreflang вели бы на несуществующие страницы
|
|
83
|
+
const isEnabled = (code: string) => !enabledCodes.value.length || enabledCodes.value.includes(code);
|
|
84
|
+
|
|
68
85
|
const otherLocales = i18n.locales.value.filter(
|
|
69
86
|
(locale) => {
|
|
70
87
|
const localeCode = typeof locale === 'string' ? locale : locale.code;
|
|
71
|
-
return localeCode !== currentLocaleCode && localeCode !== 'en';
|
|
88
|
+
return localeCode !== currentLocaleCode && localeCode !== 'en' && isEnabled(localeCode);
|
|
72
89
|
}
|
|
73
90
|
);
|
|
74
91
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type SeoOverrides = {
|
|
2
|
+
/** локаль, на которую должен вести canonical вместо текущей (у контента нет перевода) */
|
|
3
|
+
canonicalLocale?: string;
|
|
4
|
+
/** страница единственная в пагинации — из canonical выкидывается sort */
|
|
5
|
+
alonePage?: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type SeoOverridesState = SeoOverrides & {
|
|
9
|
+
path: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Уточнения к SEO-ссылкам, которые знает только страница или конкретный компонент,
|
|
14
|
+
* а выпускает теги один app.vue. Привязаны к route.path, чтобы значение
|
|
15
|
+
* не «протекало» на следующую страницу при клиентской навигации.
|
|
16
|
+
*/
|
|
17
|
+
export const useSeoOverridesState = () => useState<SeoOverridesState | null>('seo-overrides', () => null);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Текущие уточнения для активного маршрута.
|
|
21
|
+
*/
|
|
22
|
+
export const useSeoOverrides = () => {
|
|
23
|
+
const route = useRoute();
|
|
24
|
+
const state = useSeoOverridesState();
|
|
25
|
+
|
|
26
|
+
return computed<SeoOverrides>(() => state.value?.path === route.path ? state.value : {});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Записывает уточнение для текущего маршрута. Значения от разных источников
|
|
31
|
+
* (страница, пагинация) складываются; при смене маршрута состояние сбрасывается.
|
|
32
|
+
*/
|
|
33
|
+
export const useSetSeoOverride = (overrides: MaybeRefOrGetter<SeoOverrides>) => {
|
|
34
|
+
const route = useRoute();
|
|
35
|
+
const state = useSeoOverridesState();
|
|
36
|
+
|
|
37
|
+
watchEffect(() => {
|
|
38
|
+
const patch = toValue(overrides);
|
|
39
|
+
|
|
40
|
+
state.value = state.value?.path === route.path
|
|
41
|
+
? { ...state.value, ...patch }
|
|
42
|
+
: { path: route.path, ...patch };
|
|
43
|
+
});
|
|
44
|
+
};
|
package/package.json
CHANGED
package/pages/categories/all.vue
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<UiSectionTitle
|
|
8
8
|
:title="h1"
|
|
9
9
|
:count="totalCount"
|
|
10
|
-
class="
|
|
10
|
+
class="all__title"
|
|
11
11
|
icon="grid"
|
|
12
12
|
/>
|
|
13
13
|
|
|
@@ -97,4 +97,8 @@ const breadcrumbs = [
|
|
|
97
97
|
.all__popular-models {
|
|
98
98
|
margin-top: 32px;
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
.all__title {
|
|
102
|
+
margin-bottom: 16px;
|
|
103
|
+
}
|
|
100
104
|
</style>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<UiSectionTitle
|
|
9
9
|
:title="h1"
|
|
10
10
|
:count="totalCount"
|
|
11
|
-
class="
|
|
11
|
+
class="grouped__title"
|
|
12
12
|
icon="grid"
|
|
13
13
|
/>
|
|
14
14
|
|
|
@@ -100,6 +100,10 @@ const breadcrumbs = [
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
.grouped__title {
|
|
104
|
+
margin-bottom: 16px;
|
|
105
|
+
}
|
|
106
|
+
|
|
103
107
|
.grouped__popular-models {
|
|
104
108
|
display: flex;
|
|
105
109
|
column-gap: 4px;
|
package/pages/models/index.vue
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
<SectionSeoText
|
|
20
20
|
:text="t('models_footer_text')"
|
|
21
|
-
class="_container"
|
|
21
|
+
class="models__seo _container"
|
|
22
22
|
links-link="models_footer_links"
|
|
23
23
|
/>
|
|
24
24
|
</div>
|
|
@@ -85,3 +85,9 @@ const breadcrumbs = [
|
|
|
85
85
|
},
|
|
86
86
|
] as IBreadcrumbItem[];
|
|
87
87
|
</script>
|
|
88
|
+
|
|
89
|
+
<style lang="scss">
|
|
90
|
+
.models__seo {
|
|
91
|
+
margin-bottom: 24px;
|
|
92
|
+
}
|
|
93
|
+
</style>
|
package/pages/videos/[slug].vue
CHANGED
|
@@ -46,7 +46,16 @@ const [
|
|
|
46
46
|
useFetchTopModels(ModelsApiService.getTop, 24, AsyncData.TopModelsMore, String(pageName)),
|
|
47
47
|
]);
|
|
48
48
|
|
|
49
|
-
const { t } = useI18n();
|
|
49
|
+
const { t, locale } = useI18n();
|
|
50
|
+
|
|
51
|
+
const defaultLocale = useRuntimeConfig().public.locales.default;
|
|
52
|
+
|
|
53
|
+
// у видео нет перевода на текущий язык — canonical ведёт на дефолтную локаль
|
|
54
|
+
useSetSeoOverride(() => ({
|
|
55
|
+
canonicalLocale: videoData.value && !videoData.value.isCanonical && locale.value !== defaultLocale
|
|
56
|
+
? defaultLocale
|
|
57
|
+
: undefined,
|
|
58
|
+
}));
|
|
50
59
|
|
|
51
60
|
const title = computed(() => videoData.value?.title || '');
|
|
52
61
|
const description = computed(() => videoData.value?.description || '');
|
|
@@ -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
|
+
});
|
|
@@ -33,7 +33,7 @@ export interface IRawVideoData {
|
|
|
33
33
|
is_edited: boolean; // unused
|
|
34
34
|
fake_user_name: string; // unused
|
|
35
35
|
referer_phrases: null; // unused
|
|
36
|
-
is_canonical: boolean;
|
|
36
|
+
is_canonical: boolean;
|
|
37
37
|
custom_thumb_md5: string; // unused
|
|
38
38
|
channel: {
|
|
39
39
|
name: string
|