itube-specs 0.0.837 → 0.0.840
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/dropdown.vue +5 -12
- package/components/card/video/index.vue +29 -0
- package/components/filter/btn.vue +0 -6
- package/components/ui/pagination.vue +2 -12
- package/composables/__tests__/use-seo-links.test.ts +70 -9
- package/composables/use-seo-links.ts +17 -5
- 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/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
|
|
|
@@ -100,6 +100,7 @@ const props = defineProps<{
|
|
|
100
100
|
showShare?: boolean
|
|
101
101
|
showReport?: boolean
|
|
102
102
|
showRemove?: boolean
|
|
103
|
+
autoOpen?: boolean
|
|
103
104
|
}>();
|
|
104
105
|
|
|
105
106
|
const emit = defineEmits<{
|
|
@@ -117,6 +118,10 @@ function openDropdown() {
|
|
|
117
118
|
dropdownRef.value?.openDropdown();
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
onMounted(() => {
|
|
122
|
+
if (props.autoOpen) openDropdown();
|
|
123
|
+
});
|
|
124
|
+
|
|
120
125
|
function onAddPlaylist() {
|
|
121
126
|
usePlaylistAdd().openPlaylistAdd(isAuthorized, props.card, openDropdown);
|
|
122
127
|
}
|
|
@@ -146,16 +151,4 @@ defineExpose({ openDropdown });
|
|
|
146
151
|
padding: 16px 32px 16px 16px;
|
|
147
152
|
border-bottom: 1px solid $video-card-dropdown-border;
|
|
148
153
|
}
|
|
149
|
-
|
|
150
|
-
.card-video-dropdown__trigger > .ui-icon {
|
|
151
|
-
@include from-br(sm) {
|
|
152
|
-
transition: var(--transition) transform;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
.card-video-dropdown__trigger:is(:hover, :focus-visible) > .ui-icon {
|
|
157
|
-
@include from-br(sm) {
|
|
158
|
-
transform: scale($video-card-dropdown-size--hover);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
154
|
</style>
|
|
@@ -112,11 +112,25 @@
|
|
|
112
112
|
</div>
|
|
113
113
|
</div>
|
|
114
114
|
<LazyCardVideoDropdown
|
|
115
|
+
v-if="dropdownMounted"
|
|
115
116
|
:card="card"
|
|
116
117
|
:show-watch-later="featureFlags.FeatureWatchLaterEnabled"
|
|
117
118
|
class="card-video__dropdown"
|
|
118
119
|
show-report
|
|
120
|
+
auto-open
|
|
119
121
|
/>
|
|
122
|
+
<button
|
|
123
|
+
v-else
|
|
124
|
+
:aria-label="t('open_menu')"
|
|
125
|
+
class="card-video__dropdown card-video-dropdown__trigger"
|
|
126
|
+
type="button"
|
|
127
|
+
@click="dropdownMounted = true"
|
|
128
|
+
>
|
|
129
|
+
<UiIcon
|
|
130
|
+
name="dots-vertical"
|
|
131
|
+
size="18"
|
|
132
|
+
/>
|
|
133
|
+
</button>
|
|
120
134
|
</div>
|
|
121
135
|
</div>
|
|
122
136
|
</template>
|
|
@@ -151,6 +165,8 @@ const isChannelPage = computed(() => pageName === 'channels-channel');
|
|
|
151
165
|
|
|
152
166
|
const isPreviewShow = ref(false);
|
|
153
167
|
|
|
168
|
+
const dropdownMounted = ref(false);
|
|
169
|
+
|
|
154
170
|
function playVideo() {
|
|
155
171
|
isPreviewShow.value = true;
|
|
156
172
|
}
|
|
@@ -252,6 +268,18 @@ $layout-breakpoint: 250px;
|
|
|
252
268
|
}
|
|
253
269
|
}
|
|
254
270
|
|
|
271
|
+
.card-video-dropdown__trigger > .ui-icon {
|
|
272
|
+
@include from-br(sm) {
|
|
273
|
+
transition: var(--transition) transform;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.card-video-dropdown__trigger:is(:hover, :focus-visible) > .ui-icon {
|
|
278
|
+
@include from-br(sm) {
|
|
279
|
+
transform: scale($video-card-dropdown-size--hover);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
255
283
|
.card-video__resolution {
|
|
256
284
|
top: $inset-label;
|
|
257
285
|
right: $inset-label;
|
|
@@ -364,6 +392,7 @@ $layout-breakpoint: 250px;
|
|
|
364
392
|
|
|
365
393
|
font-weight: 400;
|
|
366
394
|
display: grid;
|
|
395
|
+
grid-row: 2;
|
|
367
396
|
grid-template-columns: auto 1fr;
|
|
368
397
|
align-items: center;
|
|
369
398
|
column-gap: 4px;
|
|
@@ -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,7 @@
|
|
|
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';
|
|
5
6
|
|
|
6
7
|
let mockRoute = { path: '/videos', query: {} as Record<string, any> };
|
|
7
8
|
|
|
@@ -12,36 +13,47 @@ const mockI18n = {
|
|
|
12
13
|
locales: { value: ['en', 'fr', 'de', 'ja', 'zh'] },
|
|
13
14
|
};
|
|
14
15
|
|
|
15
|
-
const mockLocalePath = (path: string, locale: string) =>
|
|
16
|
-
|
|
16
|
+
const mockLocalePath = (path: string, locale: string) => {
|
|
17
|
+
const bare = path.replace(/^\/(?:it|fr|es|ja|de|ko|ru|zh)(?=\/|$)/, '');
|
|
18
|
+
return locale === 'en' ? bare : `/${locale}${bare}`;
|
|
19
|
+
};
|
|
17
20
|
|
|
18
21
|
describe('useSeoLinks', () => {
|
|
19
22
|
beforeEach(() => {
|
|
20
23
|
mockRoute = { path: '/videos', query: {} };
|
|
21
24
|
mockI18n.locale.value = 'en';
|
|
25
|
+
useSeoOverridesState().value = null;
|
|
22
26
|
});
|
|
23
27
|
|
|
24
28
|
it('canonical URL', () => {
|
|
25
|
-
const { canonicalUrl } = useSeoLinks('https://example.com',
|
|
29
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
26
30
|
expect(canonicalUrl.value).toBe('https://example.com/videos');
|
|
27
31
|
});
|
|
28
32
|
|
|
29
33
|
it('canonical с query params', () => {
|
|
30
34
|
mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
|
|
31
|
-
const { canonicalUrl } = useSeoLinks('https://example.com',
|
|
35
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
32
36
|
expect(canonicalUrl.value).toContain('page=2');
|
|
33
37
|
expect(canonicalUrl.value).toContain('sort=popular');
|
|
34
38
|
});
|
|
35
39
|
|
|
36
40
|
it('исключает sort при alonePage', () => {
|
|
37
41
|
mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
|
|
38
|
-
|
|
42
|
+
useSeoOverridesState().value = { path: '/videos', alonePage: true };
|
|
43
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
39
44
|
expect(canonicalUrl.value).toContain('page=2');
|
|
40
45
|
expect(canonicalUrl.value).not.toContain('sort');
|
|
41
46
|
});
|
|
42
47
|
|
|
48
|
+
it('alonePage с чужого пути не применяется', () => {
|
|
49
|
+
mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
|
|
50
|
+
useSeoOverridesState().value = { path: '/models', alonePage: true };
|
|
51
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
52
|
+
expect(canonicalUrl.value).toContain('sort=popular');
|
|
53
|
+
});
|
|
54
|
+
|
|
43
55
|
it('alternate links для всех локалей', () => {
|
|
44
|
-
const { alternateLinks } = useSeoLinks('https://example.com',
|
|
56
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
45
57
|
const hreflangs = alternateLinks.value.map(l => l.hreflang);
|
|
46
58
|
expect(hreflangs).toContain('en');
|
|
47
59
|
expect(hreflangs).toContain('fr');
|
|
@@ -52,21 +64,70 @@ describe('useSeoLinks', () => {
|
|
|
52
64
|
});
|
|
53
65
|
|
|
54
66
|
it('ja маппится в ja-JP', () => {
|
|
55
|
-
const { alternateLinks } = useSeoLinks('https://example.com',
|
|
67
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
56
68
|
const jaLink = alternateLinks.value.find(l => l.hreflang === 'ja-JP');
|
|
57
69
|
expect(jaLink).toBeDefined();
|
|
58
70
|
expect(jaLink!.href).toContain('/ja/videos');
|
|
59
71
|
});
|
|
60
72
|
|
|
61
73
|
it('x-default указывает на en', () => {
|
|
62
|
-
const { alternateLinks } = useSeoLinks('https://example.com',
|
|
74
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
63
75
|
const xDefault = alternateLinks.value.find(l => l.hreflang === 'x-default');
|
|
64
76
|
expect(xDefault!.href).toBe('https://example.com/videos');
|
|
65
77
|
});
|
|
66
78
|
|
|
79
|
+
it('canonicalLocale переводит canonical на указанную локаль', () => {
|
|
80
|
+
mockRoute = { path: '/ja/videos/some-video', query: {} };
|
|
81
|
+
mockI18n.locale.value = 'ja';
|
|
82
|
+
useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
|
|
83
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
84
|
+
expect(canonicalUrl.value).toBe('https://example.com/videos/some-video');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('canonicalLocale сохраняет допустимые query params', () => {
|
|
88
|
+
mockRoute = { path: '/ja/videos/some-video', query: { page: '2', foo: 'bar' } };
|
|
89
|
+
mockI18n.locale.value = 'ja';
|
|
90
|
+
useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
|
|
91
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
92
|
+
expect(canonicalUrl.value).toContain('/videos/some-video');
|
|
93
|
+
expect(canonicalUrl.value).not.toContain('/ja/');
|
|
94
|
+
expect(canonicalUrl.value).toContain('page=2');
|
|
95
|
+
expect(canonicalUrl.value).not.toContain('foo');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('canonicalLocale убирает hreflang-альтернативы', () => {
|
|
99
|
+
mockRoute = { path: '/ja/videos/some-video', query: {} };
|
|
100
|
+
mockI18n.locale.value = 'ja';
|
|
101
|
+
useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
|
|
102
|
+
const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
103
|
+
expect(alternateLinks.value).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('canonicalLocale игнорируется для чужого пути', () => {
|
|
107
|
+
mockRoute = { path: '/ja/videos/other-video', query: {} };
|
|
108
|
+
mockI18n.locale.value = 'ja';
|
|
109
|
+
useSeoOverridesState().value = { path: '/ja/videos/some-video', canonicalLocale: 'en' };
|
|
110
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
111
|
+
expect(canonicalUrl.value).toBe('https://example.com/ja/videos/other-video');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('canonicalLocale игнорируется, если совпадает с текущей локалью', () => {
|
|
115
|
+
mockRoute = { path: '/videos/some-video', query: {} };
|
|
116
|
+
useSeoOverridesState().value = { path: '/videos/some-video', canonicalLocale: 'en' };
|
|
117
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
118
|
+
expect(canonicalUrl.value).toBe('https://example.com/videos/some-video');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('без override canonical остаётся на текущей локали', () => {
|
|
122
|
+
mockRoute = { path: '/ja/videos/some-video', query: {} };
|
|
123
|
+
mockI18n.locale.value = 'ja';
|
|
124
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
125
|
+
expect(canonicalUrl.value).toBe('https://example.com/ja/videos/some-video');
|
|
126
|
+
});
|
|
127
|
+
|
|
67
128
|
it('игнорирует неразрешённые query params', () => {
|
|
68
129
|
mockRoute = { path: '/videos', query: { page: '1', foo: 'bar', categories: 'teen' } };
|
|
69
|
-
const { canonicalUrl } = useSeoLinks('https://example.com',
|
|
130
|
+
const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
|
|
70
131
|
expect(canonicalUrl.value).toContain('page=1');
|
|
71
132
|
expect(canonicalUrl.value).toContain('categories=teen');
|
|
72
133
|
expect(canonicalUrl.value).not.toContain('foo');
|
|
@@ -7,21 +7,22 @@ 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();
|
|
17
18
|
|
|
18
19
|
const allowedParams: string[] = ['page', 'sort', 'categories'];
|
|
19
20
|
const createUrlWithParams = (path: string) => {
|
|
20
21
|
const url = new URL(`${baseDomain}${path}`);
|
|
21
22
|
|
|
22
23
|
Object.entries(route.query).forEach(([key, value]) => {
|
|
23
|
-
//
|
|
24
|
-
if (allowedParams.includes(key) && !(alonePage && key === 'sort')) {
|
|
24
|
+
// на единственной странице sort не участвует в URL
|
|
25
|
+
if (allowedParams.includes(key) && !(overrides.value.alonePage && key === 'sort')) {
|
|
25
26
|
if (Array.isArray(value)) {
|
|
26
27
|
value.forEach(v => url.searchParams.append(key, v));
|
|
27
28
|
}
|
|
@@ -41,9 +42,20 @@ export const useSeoLinks = (baseDomain: string, alonePage: boolean = false, i18n
|
|
|
41
42
|
|
|
42
43
|
const mapHreflang = (code: string) => langMap[code] || code;
|
|
43
44
|
|
|
44
|
-
const
|
|
45
|
+
const forcedCanonicalLocale = computed(() => {
|
|
46
|
+
const { canonicalLocale } = overrides.value;
|
|
47
|
+
|
|
48
|
+
return canonicalLocale && canonicalLocale !== i18n.locale.value ? canonicalLocale : null;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const canonicalUrl = computed(() => createUrlWithParams(
|
|
52
|
+
forcedCanonicalLocale.value ? localePath(route.path, forcedCanonicalLocale.value) : route.path
|
|
53
|
+
));
|
|
45
54
|
|
|
46
55
|
const alternateLinks = computed(() => {
|
|
56
|
+
// страница неканонична (нет перевода) — hreflang-кластер объявляет канонический URL, а не эта страница
|
|
57
|
+
if (forcedCanonicalLocale.value) return [];
|
|
58
|
+
|
|
47
59
|
const links = [];
|
|
48
60
|
const currentLocaleCode = i18n.locale.value;
|
|
49
61
|
|
|
@@ -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 || '');
|
|
@@ -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
|