itube-specs 0.0.872 → 0.0.873

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.
@@ -72,15 +72,15 @@
72
72
 
73
73
  <p class="auth-register__agreement">{{ t('auth.agree_begin') }}
74
74
  <NuxtLink
75
+ :to="generateLink('/terms')"
75
76
  class="auth-register__agreement-link"
76
- to="/terms"
77
77
  target="_blank"
78
78
  >{{ t('auth.agree_terms') }}
79
79
  </NuxtLink>
80
80
  {{ t('auth.agree_and') }}
81
81
  <NuxtLink
82
+ :to="generateLink('/policy')"
82
83
  class="auth-register__agreement-link"
83
- to="/policy"
84
84
  target="_blank"
85
85
  >{{ t('auth.agree_policy') }}
86
86
  </NuxtLink>
@@ -101,6 +101,7 @@ defineProps<{
101
101
  }>();
102
102
 
103
103
  const { t } = useI18n();
104
+ const { generateLink } = useGenerateLink();
104
105
 
105
106
  const usernameRef = ref();
106
107
  const passwordRef = ref();
@@ -2,7 +2,7 @@
2
2
  <template>
3
3
  <NuxtLink
4
4
  :title="card.title"
5
- :to="localePath(`/categories/${convertString().toSlug(slug)}`)"
5
+ :to="generateLink(`/categories/${convertString().toSlug(slug)}`)"
6
6
  class="card-category"
7
7
  >
8
8
  <div class="card-category__inner">
@@ -66,7 +66,7 @@ function onImageError() {
66
66
  imageError.value = true;
67
67
  }
68
68
 
69
- const localePath = useLocalePath();
69
+ const { generateLink } = useGenerateLink();
70
70
 
71
71
  const slug = computed(() => {
72
72
  return props.card.name;
@@ -5,7 +5,7 @@
5
5
  :key="`category-all-${category.guid}`"
6
6
  :data-count="category.videosCount"
7
7
  :class="{'--top': category.isTop}"
8
- :to="localePath(`/categories/${slug(category.name)}`)"
8
+ :to="generateLink(`/categories/${slug(category.name)}`)"
9
9
  class="category-list__link"
10
10
  >
11
11
  {{ category.title }}
@@ -21,7 +21,7 @@
21
21
  </NuxtLink>
22
22
  <NuxtLink
23
23
  v-if="allCategories"
24
- :to="localePath('/categories')"
24
+ :to="generateLink('/categories')"
25
25
  class="category-list__link --all"
26
26
  >
27
27
  {{ t('all_categories') }}
@@ -42,7 +42,7 @@ function slug(name: string) {
42
42
  return convertString().toSlug(name);
43
43
  }
44
44
 
45
- const localePath = useLocalePath();
45
+ const { generateLink } = useGenerateLink();
46
46
  const { t } = useI18n();
47
47
  </script>
48
48
 
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <NuxtLink
3
3
  :class="{'layout-logo--glow': glow}"
4
- :to="localePath('/')"
4
+ :to="generateLink('/')"
5
5
  class="layout-logo"
6
6
  @click="onLogoClick"
7
7
  >
@@ -23,7 +23,7 @@ const emit = defineEmits<{
23
23
  (eventName: 'click'): void
24
24
  }>();
25
25
 
26
- const localePath = useLocalePath();
26
+ const { generateLink } = useGenerateLink();
27
27
 
28
28
  function onLogoClick() {
29
29
  emit('click');
@@ -20,16 +20,18 @@
20
20
  </span>
21
21
  <span class="player-sponsored__description">{{ $t('click_to_watch') }}</span>
22
22
  <NuxtLink
23
- :to="localePath(card.site)"
23
+ :to="card.site"
24
24
  class="player-sponsored__site"
25
25
  target="_blank"
26
+ external
26
27
  >{{ card.site }}</NuxtLink>
27
28
  </span>
28
29
  <UiLink
29
- :to="localePath(card.site)"
30
+ :to="card.site"
30
31
  class="player-sponsored__button _from-sm"
31
32
  theme="primary"
32
33
  target="_blank"
34
+ external
33
35
  >{{ $t('get_full_video')}}</UiLink>
34
36
  </div>
35
37
  </template>
@@ -42,8 +44,6 @@ defineProps<{
42
44
  site: string
43
45
  }
44
46
  }>();
45
-
46
- const localePath = useLocalePath();
47
47
  </script>
48
48
 
49
49
  <style lang="scss">
@@ -41,10 +41,10 @@ const props = defineProps<{
41
41
  }>();
42
42
 
43
43
  const title = computed(() => props.item?.title?.replace(/<[^>]*>/g, ''));
44
- const localePath = useLocalePath();
44
+ const { generateLink } = useGenerateLink();
45
45
 
46
46
  const slug = computed(() => convertString().toSlug(title.value));
47
- const link = computed(() => localePath(`/${props.prefix}/${slug.value}`));
47
+ const link = computed(() => generateLink(`/${props.prefix}/${slug.value}`));
48
48
 
49
49
  const isModel = computed(() => props.prefix === 'models');
50
50
  const safeTitle = computed(() =>
@@ -88,12 +88,12 @@ watch(isResultShow, (value) => {
88
88
  }
89
89
  });
90
90
 
91
- const localePath = useLocalePath();
91
+ const { generateLink } = useGenerateLink();
92
92
 
93
93
  function goToSearch() {
94
94
  if (searchValue.value && searchValue.value.length) {
95
95
  router.push({
96
- path: localePath(`/search/${searchValue.value}`)
96
+ path: generateLink(`/search/${searchValue.value}`)
97
97
  });
98
98
  }
99
99
  }
@@ -25,7 +25,7 @@
25
25
  #append
26
26
  >
27
27
  <NuxtLink
28
- :to="allLink"
28
+ :to="generateLink(allLink ?? '/')"
29
29
  class="section-swiper-cards__all"
30
30
  >{{ allLinkText }}
31
31
  </NuxtLink>
@@ -48,6 +48,7 @@ defineProps<{
48
48
  }>();
49
49
 
50
50
  const { t } = useI18n();
51
+ const { generateLink } = useGenerateLink();
51
52
  </script>
52
53
 
53
54
  <style lang="scss">
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.872",
4
+ "version": "0.0.873",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
package/pages/2257.vue CHANGED
@@ -33,8 +33,8 @@
33
33
 
34
34
  <li>
35
35
  the uploaded content does not violate {{ brandName }}’s
36
- <a href="/terms">Terms of Service
37
- </a>
36
+ <NuxtLink :to="generateLink('/terms')">Terms of Service
37
+ </NuxtLink>
38
38
  , and any of its related policies.
39
39
  </li>
40
40
  </ul>
@@ -61,6 +61,7 @@
61
61
 
62
62
  <script setup lang="ts">
63
63
  const { t } = useI18n();
64
+ const { generateLink } = useGenerateLink();
64
65
  const pagePureName = useGetPureRouteName();
65
66
  const { brandName, emails } = useAppConfig();
66
67
  const { meta, h1 } = useMeta(t, pagePureName, brandName);
package/pages/dmca.vue CHANGED
@@ -62,12 +62,12 @@
62
62
 
63
63
  <p>
64
64
  Please submit all information at
65
- <a
65
+ <NuxtLink
66
+ :to="generateLink('/contacts?subject=dmca')"
66
67
  class="doc-page__email"
67
- href="/contacts?subject=dmca"
68
68
  rel="noopener"
69
69
  >{{ brandName }}/contacts?subject=dmca
70
- </a>
70
+ </NuxtLink>
71
71
  , or send to:
72
72
  <a
73
73
  :href="`mailto:${emails.dmca}`"
@@ -119,6 +119,7 @@
119
119
 
120
120
  <script setup lang="ts">
121
121
  const { t } = useI18n();
122
+ const { generateLink } = useGenerateLink();
122
123
  const pagePureName = useGetPureRouteName();
123
124
  const { brandName, emails } = useAppConfig();
124
125
  const { meta, h1 } = useMeta(t, pagePureName, brandName);
@@ -7,24 +7,25 @@ import { PlaylistsApiService } from '../services/api/playlists.service';
7
7
  import type { Language } from '../runtime';
8
8
 
9
9
  const { locale } = useI18n();
10
+ const { generateLink } = useGenerateLink();
10
11
  const cookie = useCookie('jwtoken');
11
12
 
12
13
  if (!cookie.value) {
13
- await navigateTo('/playlists', { replace: true });
14
+ await navigateTo(generateLink('/playlists'), { replace: true });
14
15
  } else {
15
16
  try {
16
17
  const res = await PlaylistsApiService.getUserPlaylists(locale.value as Language);
17
18
  const fav = res?.items?.find(item => item.name === 'favorites');
18
19
 
19
20
  if (fav?.id && fav?.firstVideoId) {
20
- await navigateTo(`/playlists/${fav.id}/${fav.firstVideoId}`, { replace: true });
21
+ await navigateTo(generateLink(`/playlists/${fav.id}/${fav.firstVideoId}`), { replace: true });
21
22
  } else if (fav?.id) {
22
- await navigateTo(`/playlists/${fav.id}`, { replace: true });
23
+ await navigateTo(generateLink(`/playlists/${fav.id}`), { replace: true });
23
24
  } else {
24
- await navigateTo('/playlists', { replace: true });
25
+ await navigateTo(generateLink('/playlists'), { replace: true });
25
26
  }
26
27
  } catch {
27
- await navigateTo('/playlists', { replace: true });
28
+ await navigateTo(generateLink('/playlists'), { replace: true });
28
29
  }
29
30
  }
30
31
  </script>
@@ -5,9 +5,11 @@
5
5
  // на слитые (shared) чанки без `src` — например из-за experimentalMinChunkSize —
6
6
  // остаётся render-blocking <link>, дублируя уже заинлайненные стили.
7
7
  //
8
- // Хук убирает такие <link rel="stylesheet"> из <head>, но ТОЛЬКО если контент
9
- // файла целиком присутствует в инлайне. Частично заинлайненные (s-popup) и
10
- // не заинлайненные (default — лейаут) линки делаются неблокирующими — никакого FOUC.
8
+ // Хук решает судьбу каждого такого <link> (см. decideCssLinkAction):
9
+ // заинлайнен целиком — линк убирается; заинлайнен частично — недостающие блоки
10
+ // доинлайниваются, а линк убирается (иначе браузер тянет файл и дублирует уже
11
+ // заинлайненные правила); не заинлайнен вовсе или хвост крупный — линк остаётся,
12
+ // но неблокирующим. Никакого FOUC.
11
13
  //
12
14
  // Чистая логика разбора/решения вынесена в server/utils/css-dedupe.ts (покрыта тестами),
13
15
  // здесь — только IO (чтение .css через localFetch) и обход <head>.
@@ -16,32 +18,28 @@ import {
16
18
  BUILD_ASSETS_DIR,
17
19
  STYLESHEET_LINK_RE,
18
20
  HREF_RE,
19
- splitCssRules,
20
21
  collectInlinedCss,
21
- isCssFullyInlined,
22
+ decideCssLinkAction,
22
23
  makeStylesheetAsync,
23
24
  } from '../utils/css-dedupe';
24
25
 
25
- // href -> нормализованные правила файла (null = прочитать не удалось)
26
- const cssRulesCache = new Map<string, string[] | null>();
26
+ // href -> содержимое .css-файла (null = прочитать не удалось)
27
+ const cssTextCache = new Map<string, string | null>();
27
28
 
28
- /** Читает .css по href через localFetch и кэширует его правила (null при ошибке). */
29
- const loadCssRules = async (nitroApp: any, href: string): Promise<string[] | null> => {
30
- if (cssRulesCache.has(href)) return cssRulesCache.get(href) ?? null;
29
+ /** Читает .css по href через localFetch и кэширует его содержимое (null при ошибке). */
30
+ const loadCssText = async (nitroApp: any, href: string): Promise<string | null> => {
31
+ if (cssTextCache.has(href)) return cssTextCache.get(href) ?? null;
31
32
 
32
- let rules: string[] | null = null;
33
+ let text: string | null = null;
33
34
  try {
34
35
  const res = await nitroApp.localFetch(href, {});
35
- if (res?.ok) {
36
- const text = await res.text();
37
- if (text) rules = splitCssRules(text);
38
- }
36
+ if (res?.ok) text = (await res.text()) || null;
39
37
  } catch {
40
- rules = null;
38
+ text = null;
41
39
  }
42
40
 
43
- cssRulesCache.set(href, rules);
44
- return rules;
41
+ cssTextCache.set(href, text);
42
+ return text;
45
43
  };
46
44
 
47
45
  export default defineNitroPlugin((nitroApp) => {
@@ -66,12 +64,13 @@ export default defineNitroPlugin((nitroApp) => {
66
64
  const href = tag.match(HREF_RE)?.[1];
67
65
  if (!href || !href.startsWith(BUILD_ASSETS_DIR)) continue;
68
66
 
69
- const rules = await loadCssRules(nitroApp, href);
70
- if (!rules || !rules.length) continue;
67
+ const css = await loadCssText(nitroApp, href);
68
+ if (!css) continue;
71
69
 
72
- next = isCssFullyInlined(rules, inlinedNormalized)
73
- ? next.replace(tag, '')
74
- : next.replace(tag, makeStylesheetAsync(tag));
70
+ const action = decideCssLinkAction(css, inlinedNormalized);
71
+ if (action.kind === 'drop') next = next.replace(tag, '');
72
+ else if (action.kind === 'inline-tail') next = next.replace(tag, `<style>${action.css}</style>`);
73
+ else next = next.replace(tag, makeStylesheetAsync(tag));
75
74
  }
76
75
  head[i] = next;
77
76
  }
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import {
3
3
  normalizeCss,
4
- splitCssRules,
4
+ splitCssBlocks,
5
+ findMissingCssBlocks,
5
6
  collectInlinedCss,
6
- isCssFullyInlined,
7
+ decideCssLinkAction,
7
8
  makeStylesheetAsync,
9
+ MAX_TAIL_INLINE_BYTES,
8
10
  } from '../css-dedupe';
9
11
 
10
12
  describe('normalizeCss', () => {
@@ -13,17 +15,35 @@ describe('normalizeCss', () => {
13
15
  });
14
16
  });
15
17
 
16
- describe('splitCssRules', () => {
17
- it('разбивает на нормализованные правила по }', () => {
18
- expect(splitCssRules('.a{color:red}\n.b{x:1}')).toEqual(['.a{color:red}', '.b{x:1}']);
18
+ describe('splitCssBlocks', () => {
19
+ it('разбивает на top-level блоки в исходном виде', () => {
20
+ expect(splitCssBlocks('.a{color:red}\n.b{x:1}')).toEqual(['.a{color:red}', '.b{x:1}']);
19
21
  });
20
22
 
21
- it('отбрасывает пустые фрагменты', () => {
22
- expect(splitCssRules('.a{color:red}\n\n')).toEqual(['.a{color:red}']);
23
+ it('@media остаётся одним блоком', () => {
24
+ expect(splitCssBlocks('.a{x:1}@media(min-width:960px){.b{y:2}.c{z:3}}')).toEqual([
25
+ '.a{x:1}',
26
+ '@media(min-width:960px){.b{y:2}.c{z:3}}',
27
+ ]);
28
+ });
29
+
30
+ it('statement-ы через ; на верхнем уровне — отдельные блоки', () => {
31
+ expect(splitCssBlocks('@charset "UTF-8";.a{x:1}')).toEqual(['@charset "UTF-8";', '.a{x:1}']);
23
32
  });
24
33
 
25
34
  it('пустой CSS → []', () => {
26
- expect(splitCssRules('')).toEqual([]);
35
+ expect(splitCssBlocks('')).toEqual([]);
36
+ });
37
+ });
38
+
39
+ describe('findMissingCssBlocks', () => {
40
+ it('возвращает только отсутствующие блоки, в исходном порядке', () => {
41
+ const inlined = '.a{x:1} .c{z:3}';
42
+ expect(findMissingCssBlocks(['.a{x:1}', '.b{y:2}', '.c{z:3}'], inlined)).toEqual(['.b{y:2}']);
43
+ });
44
+
45
+ it('сравнивает по нормализованному виду', () => {
46
+ expect(findMissingCssBlocks(['.a {\n x: 1;\n}'], '.a { x: 1; }')).toEqual([]);
27
47
  });
28
48
  });
29
49
 
@@ -40,19 +60,31 @@ describe('collectInlinedCss', () => {
40
60
  });
41
61
  });
42
62
 
43
- describe('isCssFullyInlined', () => {
44
- const inlined = '.a{color:red} .b{x:1} .c{y:2}';
63
+ describe('decideCssLinkAction', () => {
64
+ const inlined = '.a{color:red} .b{x:1}';
65
+
66
+ it('файл заинлайнен целиком → drop', () => {
67
+ expect(decideCssLinkAction('.a{color:red}.b{x:1}', inlined)).toEqual({ kind: 'drop' });
68
+ });
69
+
70
+ it('заинлайнен частично → inline-tail только с недостающими блоками', () => {
71
+ expect(decideCssLinkAction('.a{color:red}.z{q:9}', inlined)).toEqual({
72
+ kind: 'inline-tail',
73
+ css: '.z{q:9}',
74
+ });
75
+ });
45
76
 
46
- it('true, когда все правила есть в инлайне', () => {
47
- expect(isCssFullyInlined(['.a{color:red}', '.b{x:1}'], inlined)).toBe(true);
77
+ it('не заинлайнен вовсе → async (грузим файлом, он кэшируется)', () => {
78
+ expect(decideCssLinkAction('.z{q:9}.y{q:8}', inlined)).toEqual({ kind: 'async' });
48
79
  });
49
80
 
50
- it('false, когда хотя бы одно правило отсутствует', () => {
51
- expect(isCssFullyInlined(['.a{color:red}', '.z{q:9}'], inlined)).toBe(false);
81
+ it('крупный хвост → async, а не распухший HTML', () => {
82
+ const big = `.z{content:"${'x'.repeat(MAX_TAIL_INLINE_BYTES)}"}`;
83
+ expect(decideCssLinkAction(`.a{color:red}${big}`, inlined)).toEqual({ kind: 'async' });
52
84
  });
53
85
 
54
- it('false на пустом списке правил', () => {
55
- expect(isCssFullyInlined([], inlined)).toBe(false);
86
+ it('пустой файл → async', () => {
87
+ expect(decideCssLinkAction('', inlined)).toEqual({ kind: 'async' });
56
88
  });
57
89
  });
58
90
 
@@ -17,20 +17,46 @@ const STYLE_TAG_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
17
17
  export const normalizeCss = (css: string): string => css.replace(/\s+/g, ' ').trim();
18
18
 
19
19
  /**
20
- * Разбивает CSS на отдельные нормализованные правила (по `}`). Нужно, потому что
21
- * слитый чанк содержит CSS нескольких компонентов, и в инлайне эти правила раскиданы
22
- * по разным `<style>`-блокам — целиком файл как подстрока не находится, а по-правилам — да.
20
+ * Разбивает CSS на top-level блоки в ИСХОДНОМ виде (учитывая вложенность `@media`/`@supports`
21
+ * и statement-ы вида `@charset "UTF-8";`). Нужно, потому что слитый чанк содержит CSS
22
+ * нескольких компонентов, и в инлайне эти правила раскиданы по разным `<style>`-блокам —
23
+ * целиком файл как подстрока не находится, а по-блочно — да. В отличие от разбиения по `}`,
24
+ * блоки остаются валидным CSS, поэтому недостающие можно доинлайнить как есть.
23
25
  * @param css - CSS файла
24
26
  */
25
- export const splitCssRules = (css: string): string[] => {
26
- const rules: string[] = [];
27
- for (const frag of css.split('}')) {
28
- const rule = normalizeCss(frag + '}');
29
- if (rule && rule !== '}') rules.push(rule);
27
+ export const splitCssBlocks = (css: string): string[] => {
28
+ const blocks: string[] = [];
29
+ let depth = 0;
30
+ let buf = '';
31
+
32
+ for (const ch of css) {
33
+ buf += ch;
34
+ if (ch === '{') depth++;
35
+ else if (ch === '}') {
36
+ depth--;
37
+ if (depth <= 0) {
38
+ depth = 0;
39
+ if (buf.trim()) blocks.push(buf.trim());
40
+ buf = '';
41
+ }
42
+ } else if (ch === ';' && depth === 0) {
43
+ if (buf.trim()) blocks.push(buf.trim());
44
+ buf = '';
45
+ }
30
46
  }
31
- return rules;
47
+
48
+ if (buf.trim()) blocks.push(buf.trim());
49
+ return blocks;
32
50
  };
33
51
 
52
+ /**
53
+ * Блоки файла, которых нет в инлайне — в исходном виде и исходном порядке.
54
+ * @param blocks - блоки файла (см. splitCssBlocks)
55
+ * @param inlinedNormalized - нормализованный инлайн (см. collectInlinedCss)
56
+ */
57
+ export const findMissingCssBlocks = (blocks: string[], inlinedNormalized: string): string[] =>
58
+ blocks.filter((block) => !inlinedNormalized.includes(normalizeCss(block)));
59
+
34
60
  /**
35
61
  * Собирает содержимое всех `<style>` из частей `<head>` и возвращает нормализованным.
36
62
  * @param headParts - массив строк-частей `<head>` (html.head у Nitro)
@@ -46,13 +72,42 @@ export const collectInlinedCss = (headParts: string[]): string => {
46
72
  return normalizeCss(inlined);
47
73
  };
48
74
 
75
+ /** Сколько недостающего CSS готовы доинлайнить в HTML вместо async-линка. */
76
+ export const MAX_TAIL_INLINE_BYTES = 4096;
77
+
49
78
  /**
50
- * Все ли правила файла уже присутствуют в инлайне (тогда render-blocking `<link>` не нужен).
51
- * @param rules - нормализованные правила файла (см. splitCssRules)
79
+ * Что сделать с `<link rel=stylesheet>`:
80
+ * - `drop` — файл заинлайнен целиком, линк не нужен;
81
+ * - `inline-tail` — заинлайнен частично: доинлайниваем недостающие блоки и линк убираем
82
+ * (иначе браузер тянет файл и дублирует уже заинлайненные правила);
83
+ * - `async` — инлайна нет вовсе или хвост слишком большой: грузим файлом, но неблокирующе.
84
+ */
85
+ export type CssLinkAction =
86
+ | { kind: 'drop' }
87
+ | { kind: 'inline-tail'; css: string }
88
+ | { kind: 'async' };
89
+
90
+ /**
91
+ * Решает судьбу линка по содержимому файла и инлайну страницы.
92
+ * @param css - содержимое .css-файла
52
93
  * @param inlinedNormalized - нормализованный инлайн (см. collectInlinedCss)
53
94
  */
54
- export const isCssFullyInlined = (rules: string[], inlinedNormalized: string): boolean =>
55
- rules.length > 0 && rules.every((rule) => inlinedNormalized.includes(rule));
95
+ export const decideCssLinkAction = (css: string, inlinedNormalized: string): CssLinkAction => {
96
+ const blocks = splitCssBlocks(css);
97
+ if (!blocks.length) return { kind: 'async' };
98
+
99
+ const missing = findMissingCssBlocks(blocks, inlinedNormalized);
100
+ if (!missing.length) return { kind: 'drop' };
101
+
102
+ // Ничего из файла не заинлайнено (лейаут, popup-и) — дублирования нет, оставляем файлом:
103
+ // он кэшируется браузером, в отличие от инлайна в HTML.
104
+ if (missing.length === blocks.length) return { kind: 'async' };
105
+
106
+ const tail = missing.join('');
107
+ if (tail.length > MAX_TAIL_INLINE_BYTES) return { kind: 'async' };
108
+
109
+ return { kind: 'inline-tail', css: tail };
110
+ };
56
111
 
57
112
  /**
58
113
  * Делает `<link rel=stylesheet>` неблокирующим: `media="print"` снимает render-block,