itube-specs 0.0.1040 → 0.0.1042

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/SITE_UPDATES.md CHANGED
@@ -11,6 +11,20 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1041 — 2026-10-19
15
+
16
+ - **Сайтам со своим `composables/use-meta.ts` или форком `pages/videos/[slug].vue` ради меты (videosection-redesign):**
17
+ правила меты videosection.com переехали в слой. `useMeta`: для страницы > 1 сначала ищется ключ `<key>_paged` (с
18
+ `{p}` — номером), мета из API (`seo`) может идти через шаблоны `api_title` / `api_description` (+ `_paged`) с
19
+ `{text}` = значение API; нет таких ключей — всё как раньше. `useCategoryPage(pageData, { metaKeys: true })` — мета и
20
+ h1 категории через `useMeta` по ключам (`{text}` — название, анти-дубль слова ниши), мета категории из API — через
21
+ `api_*`. `useSearchPage(relatedSearch, { metaSortOptions })` — уровень сортировки в ключах меты поиска.
22
+ `appConfig.videoPage.metaFromPhrases` — description ролика из фраз перехода / тегов / категорий (склейка ключами
23
+ `meta.videos-slug.tags_and` / `tags_tail`), `{thirdText}` — название в Title Case. Свой `use-meta.ts` и форк страницы
24
+ видео удалить (образец — коммит videosection-redesign с этим изменением). Не сделать — копии продолжат работать,
25
+ но будут отставать от слоя (в форке страницы видео, например, остаётся пустой «Recent searches»). Остальным —
26
+ ничего, по умолчанию мета как была.
27
+
14
28
  ## 0.0.1040 — 2026-10-19
15
29
 
16
30
  - **Сайтам со своими `utils/adverts-helper.ts` / `utils/popunder-helper.ts` (videosection-redesign):** не копировать
package/app.config.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { INavExtraItem, INavItemOverride, IQuickFilterScheme, ISecondNavItem } from './types';
2
+
3
+ export default defineAppConfig({
4
+ // Дефолты, чтобы слой собирался/тестировался standalone.
5
+ // Проект deep-merge'ит свои per-site значения поверх.
6
+ theme: 'dark',
7
+ quickVideoFilters: [] as IQuickFilterScheme[],
8
+ logoSize: null as { width: number, height: number } | null,
9
+ secondNav: [] as ISecondNavItem[],
10
+ /** Правки пунктов главной навигации слоя по их `title` (подпись, ссылка по нишам). */
11
+ navOverrides: {} as Record<string, INavItemOverride>,
12
+ /** Дополнительные пункты главной навигации, каждый — за пунктом слоя `after`. */
13
+ navExtraItems: [] as INavExtraItem[],
14
+ /** Есть ли у сайта сгруппированный вид категорий (`/categories` — группы, `/categories/all` — A–Z).
15
+ * `false` — `/categories` сам список A–Z: `CategoryNav` без табов Grouped / A-Z, «All» ведёт на `/categories`. */
16
+ categoriesGrouped: true,
17
+ /** Блок Live Cams в сетках листингов (при `FeatureWebcamsEnabled`), `null` — нет. `videos` — после какой строки
18
+ * карточек и сколько врезок вебкам-тумб (хвост того же списка) в сетке видео листинга (не похожие, не плейлист,
19
+ * не когда страница передала врезки сама); `models` — в основной сетке моделей (`priority`, не футер). */
20
+ gridWebcams: {
21
+ videos: null as { afterRows: number, inserts: number } | null,
22
+ models: null as { afterRows: number } | null,
23
+ },
24
+ /** Класс кликабельной части карточек в сетках видео и моделей — для сайтовых хуков (триггер попандера). */
25
+ cardTriggerClass: '',
26
+ /** Состав страницы видео. */
27
+ videoPage: {
28
+ /** Фразы, по которым пришли на ролик, — строкой над заголовком. */
29
+ refererPhrases: false,
30
+ /** Пагинация похожих. */
31
+ relatedPagination: true,
32
+ /** Карусель моделей под похожими. */
33
+ relatedModels: true,
34
+ /** Блок Live Cams после похожих (при `FeatureWebcamsEnabled`); врезки в похожие берут хвост того же списка. */
35
+ webcamsBlock: false,
36
+ /** Сколько моделей из хвоста списка уходит во врезки похожих, когда блок Live Cams включён. */
37
+ webcamInserts: 8,
38
+ /** Мета ролика как на старых движках: description — до трёх фраз перехода (недостающие — из тегов), без них —
39
+ * категории, склейка ключами `meta.videos-slug.tags_and` / `tags_tail`; в title доступен `{thirdText}` — название
40
+ * в Title Case. Выключено — description из описания ролика. */
41
+ metaFromPhrases: false,
42
+ },
43
+ });
@@ -25,6 +25,7 @@
25
25
 
26
26
  .main {
27
27
  display: grid;
28
+ grid-template-columns: minmax(0, 1fr);
28
29
  flex-grow: 1;
29
30
  }
30
31
 
@@ -18,6 +18,7 @@
18
18
  text-transform: uppercase;
19
19
  letter-spacing: var(--tracking-wide);
20
20
  font-weight: 700;
21
+ overflow-wrap: break-word;
21
22
 
22
23
  @include from-br(xm) {
23
24
  @include font-lg;
@@ -319,6 +319,7 @@ $updated-word-width: 240px;
319
319
  .card-playlist {
320
320
  position: relative;
321
321
  display: grid;
322
+ grid-template-columns: minmax(0, 1fr);
322
323
  width: 100%;
323
324
  container-type: inline-size;
324
325
  align-items: start;
@@ -383,8 +384,10 @@ $updated-word-width: 240px;
383
384
  .card-playlist__wrapper-header {
384
385
  display: flex;
385
386
  align-items: center;
387
+ flex-wrap: wrap;
386
388
  justify-content: space-between;
387
389
  column-gap: 24px;
390
+ row-gap: 4px;
388
391
  margin-bottom: 8px;
389
392
  }
390
393
 
@@ -84,7 +84,15 @@ const resultItems = computed(() => {
84
84
  color: $breadcrumbs-text;
85
85
  }
86
86
 
87
+ .ui-breadcrumbs ol li:last-child {
88
+ min-width: 0;
89
+ }
90
+
87
91
  .ui-breadcrumbs ol li:last-child .ui-breadcrumbs__item {
92
+ min-width: 0;
93
+ overflow: hidden;
94
+ white-space: nowrap;
95
+ text-overflow: ellipsis;
88
96
  color: $breadcrumbs-text--current;
89
97
  text-transform: capitalize;
90
98
  }
@@ -1,5 +1,6 @@
1
1
  import { CategoriesApiService } from '../../services/api/categories.service';
2
2
  import { VideosApiService } from '../../services/api/videos.service';
3
+ import { sortItemsDefault } from '../../lib';
3
4
  import type { IBreadcrumbItem, ICardInfo, IChipsItem } from '../../types';
4
5
 
5
6
  /** Слаги категорий, у которых не показываем связанные поисковые фразы. */
@@ -27,8 +28,15 @@ export async function useCategoryPageData() {
27
28
  /**
28
29
  * Логика страницы категории поверх `useCategoryPageData()`: мета и 404, хлебные крошки, номер страницы,
29
30
  * карточка инфо и чипсы связанных фраз. Синхронная — вызывать в setup после `await useCategoryPageData()`.
31
+ *
32
+ * Мета по умолчанию — из API (`metaTitle` / `metaDescription`, без них — название), h1 — `header` или название.
33
+ * `metaKeys: true` — мета и h1 через `useMeta` по ключам `meta.<page>.<sort>.*` (`{text}` — название, с анти-дублем
34
+ * слова ниши); мета из API идёт через шаблоны `api_title` / `api_description`, пустая — по ключам.
30
35
  */
31
- export function useCategoryPage(pageData: Awaited<ReturnType<typeof useCategoryPageData>>) {
36
+ export function useCategoryPage(
37
+ pageData: Awaited<ReturnType<typeof useCategoryPageData>>,
38
+ options: { metaKeys?: boolean } = {},
39
+ ) {
32
40
  const { categoryInfo, data, status, relatedData } = pageData;
33
41
  const { t } = useI18n();
34
42
  const route = useRoute();
@@ -39,7 +47,25 @@ export function useCategoryPage(pageData: Awaited<ReturnType<typeof useCategoryP
39
47
  const pageText = computed(() => (pageNumber.value === 1 ? '' : ` ${t('page')} ${pageNumber.value}`));
40
48
 
41
49
  const title = computed(() => categoryInfo.value?.title || '');
42
- const h1 = computed(() => categoryInfo.value?.header || title.value);
50
+
51
+ const keysMeta = options.metaKeys
52
+ ? useMeta({
53
+ t,
54
+ pageKey: useGetPureRouteName(),
55
+ brandName: useAppConfig().brandName,
56
+ sortOptions: sortItemsDefault,
57
+ text: title,
58
+ nicheSafeText: title,
59
+ seo: () => ({
60
+ title: categoryInfo.value?.metaTitle || '',
61
+ description: categoryInfo.value?.metaDescription || '',
62
+ h1: '',
63
+ keywords: '',
64
+ }),
65
+ })
66
+ : null;
67
+
68
+ const h1 = computed(() => (keysMeta ? keysMeta.h1.value : categoryInfo.value?.header || title.value));
43
69
 
44
70
  const categoryInfoResult = computed(() => ({
45
71
  title: h1.value,
@@ -68,17 +94,21 @@ export function useCategoryPage(pageData: Awaited<ReturnType<typeof useCategoryP
68
94
  ]);
69
95
 
70
96
  watchEffect(() => {
71
- const metaTitle = `${categoryInfo.value?.metaTitle || title.value}${pageText.value}`;
72
- const metaDescription = `${categoryInfo.value?.metaDescription || ''}${pageText.value}`;
73
-
74
- useHead({
75
- title: metaTitle,
76
- meta: [
77
- { name: 'description', content: metaDescription },
78
- { property: 'og:title', content: metaTitle },
79
- { property: 'og:description', content: metaDescription },
80
- ],
81
- });
97
+ if (keysMeta) {
98
+ useHead(keysMeta.meta.value);
99
+ } else {
100
+ const metaTitle = `${categoryInfo.value?.metaTitle || title.value}${pageText.value}`;
101
+ const metaDescription = `${categoryInfo.value?.metaDescription || ''}${pageText.value}`;
102
+
103
+ useHead({
104
+ title: metaTitle,
105
+ meta: [
106
+ { name: 'description', content: metaDescription },
107
+ { property: 'og:title', content: metaTitle },
108
+ { property: 'og:description', content: metaDescription },
109
+ ],
110
+ });
111
+ }
82
112
 
83
113
  if (status.value === 'success' && (!data.value || !categoryInfo.value)) {
84
114
  throw createError({ statusCode: 404, statusMessage: 'Not Found' });
@@ -1,6 +1,6 @@
1
1
  import type { Ref } from 'vue';
2
2
  import { convertString } from '../../runtime';
3
- import type { IBreadcrumbItem, IChipsItem } from '../../types';
3
+ import type { IBreadcrumbItem, IChipsItem, INavLinkItem } from '../../types';
4
4
 
5
5
  const RECENT_SEARCHES_KEY = 'recentSearches';
6
6
  const RECENT_SEARCHES_LIMIT = 10;
@@ -8,9 +8,13 @@ const RECENT_SEARCHES_LIMIT = 10;
8
8
  /**
9
9
  * Логика страницы поиска, общая для любой разметки выдачи: мета и h1 по фразе, хлебные крошки, чипсы
10
10
  * связанных фраз (без самой фразы) и запоминание фразы в недавних поисках. Запросы выдачи у страниц разные —
11
- * их страница делает сама и передаёт сюда связанные фразы.
11
+ * их страница делает сама и передаёт сюда связанные фразы. `metaSortOptions` — сортировки выдачи, если у меты
12
+ * поиска есть варианты по ним (`meta.<page>.<sort>.*`); по умолчанию без уровня сортировки.
12
13
  */
13
- export function useSearchPage(relatedSearch: Ref<string[] | null | undefined>) {
14
+ export function useSearchPage(
15
+ relatedSearch: Ref<string[] | null | undefined>,
16
+ options: { metaSortOptions?: INavLinkItem[] } = {},
17
+ ) {
14
18
  const slug = useSlug();
15
19
  const { t } = useI18n();
16
20
 
@@ -20,7 +24,7 @@ export function useSearchPage(relatedSearch: Ref<string[] | null | undefined>) {
20
24
  t,
21
25
  pageKey: useGetPureRouteName(),
22
26
  brandName: useAppConfig().brandName,
23
- sortOptions: [],
27
+ sortOptions: options.metaSortOptions || [],
24
28
  text: metaTitle,
25
29
  });
26
30
 
@@ -51,10 +51,16 @@ type MetaParams = {
51
51
  };
52
52
 
53
53
  /**
54
- * Генерирует реактивные meta (title, description) и h1 с учётом сортировки, страницы и i18n-ключей.
55
- * Для не-дефолтной ниши сначала ищется вариант ключа `<key>_<niche>` (meta.index.trending.h1_gay)
56
- * с теми же подстановками, без него — базовый ключ.
57
- */
54
+ * Генерирует реактивные meta (title, description) и h1 с учётом сортировки, страницы и i18n-ключей.
55
+ * Для не-дефолтной ниши сначала ищется вариант ключа `<key>_<niche>` (meta.index.trending.h1_gay)
56
+ * с теми же подстановками, без него — базовый ключ.
57
+ *
58
+ * Страницы пагинации могут иметь свои тексты: для страницы > 1 сначала ищется ключ `<key>_paged` с подстановкой
59
+ * `{p}` (номер), без него — базовый ключ с `{page}`. Ниша решается раньше страницы: `<key>_paged_<niche>` →
60
+ * `<key>_<niche>`, без нишевого варианта — `<key>_paged` → `<key>`.
61
+ * Мета от API (`seo`) может идти через шаблон `api_title` / `api_description` (+ `_paged`) с `{text}` = значением
62
+ * API — сортировка добавляет префикс, страница суффикс. Нет таких ключей — всё как раньше.
63
+ */
58
64
  export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, secondText, thirdText, fourthText, h1Key, seo, nicheSafeText }: MetaParams) {
59
65
  const route = useRoute();
60
66
  const { te, locale } = useI18n();
@@ -74,11 +80,13 @@ export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, second
74
80
  return queryValue && values?.includes(queryValue) ? queryValue : defaultValue;
75
81
  });
76
82
 
77
- const pageSuffix = computed(() => {
78
- const pageNumber = route.query?.['page'] ? Number(route.query['page']) : 1;
79
- return pageNumber === 1 ? '' : ` ${t('page')} ${pageNumber}`;
83
+ const pageNumber = computed(() => {
84
+ const raw = Number(route.query?.['page']);
85
+ return Number.isFinite(raw) && raw > 1 ? Math.floor(raw) : 1;
80
86
  });
81
87
 
88
+ const pageSuffix = computed(() => (pageNumber.value === 1 ? '' : ` ${t('page')} ${pageNumber.value}`));
89
+
82
90
  const isNicheSuppressed = computed(() => {
83
91
  const probe = toValue(nicheSafeText);
84
92
  if (!probe || niche.value === defaultNiche) return false;
@@ -97,20 +105,48 @@ export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, second
97
105
  return te(variantKey) ? variantKey : baseKey;
98
106
  }
99
107
 
108
+ function fullKey(key: string) {
109
+ return `meta.${pageKey}.${sortOptions && sortOptions.length > 0 ? `${sortType.value}.` : ''}${key}`;
110
+ }
111
+
112
+ /**
113
+ * Ключ с учётом страницы: на странице > 1 — `<key>_paged`, если он переведён. Ниша решается раньше страницы:
114
+ * у ключа с нишевым вариантом страничный берётся только нишевый (`_paged_<niche>`), иначе страничный
115
+ * вариант другой ниши подменил бы текст.
116
+ */
117
+ function pagedKey(key: string) {
118
+ const base = fullKey(key);
119
+ const resolved = resolveKey(base);
120
+
121
+ if (pageNumber.value > 1) {
122
+ const paged = resolved === base ? `${base}_paged` : `${base}_paged_${niche.value}`;
123
+ if (te(paged)) return paged;
124
+ }
125
+
126
+ return resolved;
127
+ }
128
+
129
+ function params(extra: Record<string, unknown> = {}) {
130
+ return {
131
+ text: toValue(text),
132
+ secondText: toValue(secondText),
133
+ thirdText: toValue(thirdText),
134
+ fourText: toValue(fourthText),
135
+ brandName: `| ${brandName}`, //черточка '|' нужна обязательно
136
+ page: pageSuffix.value,
137
+ p: pageNumber.value,
138
+ ...extra,
139
+ };
140
+ }
141
+
100
142
  function getPath(key: string) {
101
- const pageText = pageSuffix.value;
102
-
103
- return t(
104
- resolveKey(`meta.${pageKey}.${sortOptions && sortOptions.length > 0 ? `${sortType.value}.` : ''}${key}`),
105
- {
106
- text: toValue(text),
107
- secondText: toValue(secondText),
108
- thirdText: toValue(thirdText),
109
- fourText: toValue(fourthText),
110
- brandName: `| ${brandName}`, //черточка '|' нужна обязательно
111
- page: pageText,
112
- }
113
- );
143
+ return t(pagedKey(key), params());
144
+ }
145
+
146
+ /** Значение API через шаблон `api_<key>` (префикс сортировки, суффикс страницы), если он есть. */
147
+ function fromApi(key: string, value: string, fallback: () => string) {
148
+ const templated = pagedKey(`api_${key}`);
149
+ return te(templated) ? t(templated, params({ text: value })) : fallback();
114
150
  }
115
151
 
116
152
  const apiSeo = computed(() => toValue(seo) || null);
@@ -131,14 +167,16 @@ export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, second
131
167
 
132
168
  const metaTitle = computed(() => {
133
169
  const apiTitle = apiSeo.value?.title;
134
- return apiTitle ? withPageSuffix(apiTitle) : getPath('title');
170
+ return apiTitle ? fromApi('title', apiTitle, () => withPageSuffix(apiTitle)) : getPath('title');
135
171
  });
136
172
 
137
173
  const h1 = computed(() => apiSeo.value?.h1 || getPath(h1Key?.(sortType.value) || 'h1'));
138
174
 
139
175
  const meta = computed(() => {
140
176
  const apiDescription = apiSeo.value?.description;
141
- const description = apiDescription ? `${apiDescription}${pageSuffix.value}` : getPath('meta_description');
177
+ const description = apiDescription
178
+ ? fromApi('description', apiDescription, () => `${apiDescription}${pageSuffix.value}`)
179
+ : getPath('meta_description');
142
180
  const keywords = apiSeo.value?.keywords;
143
181
 
144
182
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1040",
4
+ "version": "0.0.1042",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -86,6 +86,7 @@
86
86
  "scripts/spec.sh",
87
87
  "SITE_UPDATES.md",
88
88
  "nuxt.config.ts",
89
+ "app.config.ts",
89
90
  "DOCS/"
90
91
  ],
91
92
  "devDependencies": {
@@ -34,6 +34,7 @@ import { CSS_BREAKPOINTS } from '../../config';
34
34
  const featureFlags = useRuntimeConfig().public.featureFlags;
35
35
 
36
36
  const pageName = useGetPureRouteName();
37
+ const metaFromPhrases = !!useAppConfig().videoPage?.metaFromPhrases;
37
38
 
38
39
  const fetchRelated = featureFlags.VectorRelatedVideosEnabled
39
40
  ? useFetchVectorRelatedVideos(featureFlags.FetchByUrlEnabled ? VideosApiService.getVectorRelatedVideosByUrl : VideosApiService.getVectorRelatedVideosById)
@@ -44,11 +45,15 @@ const [
44
45
  { data: relatedVideos, status },
45
46
  { data: relatedSearch },
46
47
  { data: dataModels },
48
+ { data: refererPhrases },
47
49
  ] = await Promise.all([
48
50
  useFetchVideo(featureFlags.FetchByUrlEnabled ? VideosApiService.getVideoByUrl : VideosApiService.getVideoById),
49
51
  fetchRelated,
50
52
  useFetchRelatedSearch(SearchApiService.getPhrases),
51
53
  useFetchTopModels(ModelsApiService.getTop, 24, AsyncData.TopModelsMore, String(pageName)),
54
+ metaFromPhrases
55
+ ? useFetchVideoRefererPhrases(featureFlags.FetchByUrlEnabled ? VideosApiService.refererPhrasesByUrl : VideosApiService.refererPhrasesById)
56
+ : { data: ref<string[] | null>(null) },
52
57
  ]);
53
58
 
54
59
  // Модели ролика для боковой колонки: у каждой своя карточка и её ролики. Запрос
@@ -75,7 +80,24 @@ useSetSeoOverride(() => ({
75
80
  }));
76
81
 
77
82
  const title = computed(() => videoData.value?.title || '');
78
- const description = computed(() => videoData.value?.description || '');
83
+
84
+ // Go `title`: заглавная у каждого слова
85
+ const titleCase = (value: string) => value.replace(/(^|\s)(\S)/g, (_, space: string, letter: string) => `${space}${letter.toUpperCase()}`);
86
+
87
+ // `videoPage.metaFromPhrases`: до трёх фраз перехода (недостающие — из тегов), без них — категории.
88
+ // Описание самого ролика старый движок брал только у отредактированных вручную (`is_edited`), этот признак API не отдаёт.
89
+ const phrasesDescription = computed(() => {
90
+ const phrases = Array.isArray(refererPhrases.value) ? refererPhrases.value : [];
91
+ const tags = videoData.value?.tags || [];
92
+ const categories = (videoData.value?.categories || []).map(item => item.title);
93
+ const pick = (index: number) => phrases[index] || (phrases.length <= index ? tags[index] : '') || '';
94
+ const parts = [0, 1, 2].map(index => titleCase(pick(index) || categories[index] || ''));
95
+ const joined = `${parts[0]}${parts[1] ? `, ${parts[1]}` : ''}${parts[2] ? `${t('meta.videos-slug.tags_and')}${parts[2]}` : ''}`;
96
+
97
+ return joined ? `${joined}${t('meta.videos-slug.tags_tail')}` : '';
98
+ });
99
+
100
+ const description = computed(() => (metaFromPhrases && phrasesDescription.value) || videoData.value?.description || '');
79
101
  const pagePureName = useGetPureRouteName();
80
102
  const { meta } = useMeta({
81
103
  t,
@@ -84,6 +106,7 @@ const { meta } = useMeta({
84
106
  sortOptions: [],
85
107
  text: title,
86
108
  secondText: description,
109
+ thirdText: metaFromPhrases ? () => titleCase(title.value) : undefined,
87
110
  });
88
111
 
89
112
  const webpThumbs = videoData.value?.thumbUrls?.webp;