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.
- package/components/auth/register.vue +3 -2
- package/components/card/category.vue +2 -2
- package/components/category/list.vue +3 -3
- package/components/layout/logo.vue +2 -2
- package/components/player/sponsored.vue +4 -4
- package/components/search/item.vue +2 -2
- package/components/search/main.vue +2 -2
- package/components/section/swiper-cards.vue +2 -1
- package/package.json +1 -1
- package/pages/2257.vue +3 -2
- package/pages/dmca.vue +4 -3
- package/pages/favorites.vue +6 -5
- package/server/plugins/dedupe-inlined-css.ts +22 -23
- package/server/utils/__tests__/css-dedupe.test.ts +48 -16
- package/server/utils/css-dedupe.ts +68 -13
|
@@ -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="
|
|
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
|
|
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="
|
|
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="
|
|
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
|
|
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="
|
|
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
|
|
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="
|
|
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="
|
|
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
|
|
44
|
+
const { generateLink } = useGenerateLink();
|
|
45
45
|
|
|
46
46
|
const slug = computed(() => convertString().toSlug(title.value));
|
|
47
|
-
const link = computed(() =>
|
|
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
|
|
91
|
+
const { generateLink } = useGenerateLink();
|
|
92
92
|
|
|
93
93
|
function goToSearch() {
|
|
94
94
|
if (searchValue.value && searchValue.value.length) {
|
|
95
95
|
router.push({
|
|
96
|
-
path:
|
|
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
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
|
-
<
|
|
37
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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);
|
package/pages/favorites.vue
CHANGED
|
@@ -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}
|
|
21
|
+
await navigateTo(generateLink(`/playlists/${fav.id}/${fav.firstVideoId}`), { replace: true });
|
|
21
22
|
} else if (fav?.id) {
|
|
22
|
-
await navigateTo(`/playlists/${fav.id}
|
|
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
|
-
// Хук
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
22
|
+
decideCssLinkAction,
|
|
22
23
|
makeStylesheetAsync,
|
|
23
24
|
} from '../utils/css-dedupe';
|
|
24
25
|
|
|
25
|
-
// href ->
|
|
26
|
-
const
|
|
26
|
+
// href -> содержимое .css-файла (null = прочитать не удалось)
|
|
27
|
+
const cssTextCache = new Map<string, string | null>();
|
|
27
28
|
|
|
28
|
-
/** Читает .css по href через localFetch и кэширует его
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
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
|
|
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
|
-
|
|
38
|
+
text = null;
|
|
41
39
|
}
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
return
|
|
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
|
|
70
|
-
if (!
|
|
67
|
+
const css = await loadCssText(nitroApp, href);
|
|
68
|
+
if (!css) continue;
|
|
71
69
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
4
|
+
splitCssBlocks,
|
|
5
|
+
findMissingCssBlocks,
|
|
5
6
|
collectInlinedCss,
|
|
6
|
-
|
|
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('
|
|
17
|
-
it('разбивает на
|
|
18
|
-
expect(
|
|
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(
|
|
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(
|
|
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('
|
|
44
|
-
const inlined = '.a{color:red} .b{x:1}
|
|
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('
|
|
47
|
-
expect(
|
|
77
|
+
it('не заинлайнен вовсе → async (грузим файлом, он кэшируется)', () => {
|
|
78
|
+
expect(decideCssLinkAction('.z{q:9}.y{q:8}', inlined)).toEqual({ kind: 'async' });
|
|
48
79
|
});
|
|
49
80
|
|
|
50
|
-
it('
|
|
51
|
-
|
|
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('
|
|
55
|
-
expect(
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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
|
|
55
|
-
|
|
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,
|