itube-specs 0.0.806 → 0.0.808
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/package.json +1 -1
- package/plugins/sentry.client.ts +33 -0
- package/server/middleware/country.ts +14 -0
- package/server/middleware/session.ts +26 -0
- package/server/plugins/dedupe-inlined-css.ts +79 -0
- package/server/plugins/init.server.ts +14 -0
- package/server/plugins/sentry.ts +18 -0
- package/server/tasks/site_info.ts +21 -0
- package/server/utils/__tests__/css-dedupe.test.ts +67 -0
- package/server/utils/__tests__/lang-prop.test.ts +27 -0
- package/server/utils/api-fetch.ts +28 -0
- package/server/utils/cached-fetch.ts +68 -0
- package/server/utils/css-dedupe.ts +65 -0
- package/server/utils/lang-prop.ts +19 -0
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Инициализирует Sentry на клиенте. Два режима:
|
|
2
|
+
// - мягкий (флаг выключен): клиент не грузится вовсе, кроме ручного дебага через
|
|
3
|
+
// localStorage.sentry_debug=1 — тогда собираются только ошибки, без трейсов и replay;
|
|
4
|
+
// - полный (SentryFullCollectionEnabled): трейсы, session replay на 100% и tracking
|
|
5
|
+
// компонентов. Грузит производительность.
|
|
6
|
+
// SDK всегда импортируется динамически, чтобы не попадать в бандл в мягком режиме.
|
|
7
|
+
export default defineNuxtPlugin(async (nuxtApp) => {
|
|
8
|
+
const full = useRuntimeConfig().public.featureFlags.SentryFullCollectionEnabled;
|
|
9
|
+
const enabled = full || localStorage.getItem('sentry_debug') === '1';
|
|
10
|
+
if (!enabled) return;
|
|
11
|
+
|
|
12
|
+
const Sentry = await import('@sentry/nuxt');
|
|
13
|
+
|
|
14
|
+
const integrations = [
|
|
15
|
+
Sentry.vueIntegration({
|
|
16
|
+
app: nuxtApp.vueApp,
|
|
17
|
+
attachErrorHandler: true,
|
|
18
|
+
trackComponents: full,
|
|
19
|
+
}),
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
if (full) {
|
|
23
|
+
integrations.push(Sentry.browserTracingIntegration(), Sentry.replayIntegration());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Sentry.init({
|
|
27
|
+
dsn: useRuntimeConfig().public.sentryDsn,
|
|
28
|
+
tracesSampleRate: full ? 1.0 : 0,
|
|
29
|
+
replaysSessionSampleRate: full ? 1.0 : 0,
|
|
30
|
+
replaysOnErrorSampleRate: full ? 1.0 : 0,
|
|
31
|
+
integrations,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Определяет страну клиента и кладёт ISO-код в event.context.clientCountryCode:
|
|
2
|
+
// сначала из cookie `clientCountryCode`, иначе из заголовков прокси (Cloudflare
|
|
3
|
+
// `cf-ipcountry` / `x-country-code` / `x-country`).
|
|
4
|
+
export default defineEventHandler((event) => {
|
|
5
|
+
const cookie = getCookie(event, 'clientCountryCode');
|
|
6
|
+
if (cookie) {
|
|
7
|
+
event.context.clientCountryCode = cookie;
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const headers = event.node.req.headers;
|
|
12
|
+
const code = (headers['cf-ipcountry'] || headers['x-country-code'] || headers['x-country']) as string | undefined;
|
|
13
|
+
event.context.clientCountryCode = code?.trim().toLowerCase() || '';
|
|
14
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Для HTML-страниц гарантирует session_id: читает cookie или заводит новый
|
|
2
|
+
// (httpOnly, 30 дней) и кладёт его в event.context.sessionId.
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { getCookie, setCookie } from 'h3';
|
|
5
|
+
|
|
6
|
+
export default defineEventHandler((event) => {
|
|
7
|
+
const req = event.node.req;
|
|
8
|
+
const accept = req.headers.accept || '';
|
|
9
|
+
|
|
10
|
+
// Только HTML-страницы
|
|
11
|
+
if (!accept.includes('text/html')) return;
|
|
12
|
+
|
|
13
|
+
let sessionId = getCookie(event, 'session_id');
|
|
14
|
+
if (!sessionId) {
|
|
15
|
+
sessionId = randomUUID();
|
|
16
|
+
setCookie(event, 'session_id', sessionId, {
|
|
17
|
+
httpOnly: true,
|
|
18
|
+
path: '/',
|
|
19
|
+
maxAge: 60 * 60 * 24 * 30,
|
|
20
|
+
secure: process.env.NODE_ENV === 'production',
|
|
21
|
+
sameSite: 'lax'
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
event.context.sessionId = sessionId;
|
|
26
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// server/plugins/dedupe-inlined-css.ts
|
|
2
|
+
//
|
|
3
|
+
// Nuxt инлайнит CSS компонентов в <head>, но его дедуп вычищает <link> из
|
|
4
|
+
// манифеста только для чанков, у которых есть `src`. CSS компонентов, попавших
|
|
5
|
+
// на слитые (shared) чанки без `src` — например из-за experimentalMinChunkSize —
|
|
6
|
+
// остаётся render-blocking <link>, дублируя уже заинлайненные стили.
|
|
7
|
+
//
|
|
8
|
+
// Хук убирает такие <link rel="stylesheet"> из <head>, но ТОЛЬКО если контент
|
|
9
|
+
// файла целиком присутствует в инлайне. Частично заинлайненные (s-popup) и
|
|
10
|
+
// не заинлайненные (default — лейаут) линки делаются неблокирующими — никакого FOUC.
|
|
11
|
+
//
|
|
12
|
+
// Чистая логика разбора/решения вынесена в server/utils/css-dedupe.ts (покрыта тестами),
|
|
13
|
+
// здесь — только IO (чтение .css через localFetch) и обход <head>.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
BUILD_ASSETS_DIR,
|
|
17
|
+
STYLESHEET_LINK_RE,
|
|
18
|
+
HREF_RE,
|
|
19
|
+
splitCssRules,
|
|
20
|
+
collectInlinedCss,
|
|
21
|
+
isCssFullyInlined,
|
|
22
|
+
makeStylesheetAsync,
|
|
23
|
+
} from '../utils/css-dedupe';
|
|
24
|
+
|
|
25
|
+
// href -> нормализованные правила файла (null = прочитать не удалось)
|
|
26
|
+
const cssRulesCache = new Map<string, string[] | null>();
|
|
27
|
+
|
|
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;
|
|
31
|
+
|
|
32
|
+
let rules: string[] | null = null;
|
|
33
|
+
try {
|
|
34
|
+
const res = await nitroApp.localFetch(href, {});
|
|
35
|
+
if (res?.ok) {
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
if (text) rules = splitCssRules(text);
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
rules = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cssRulesCache.set(href, rules);
|
|
44
|
+
return rules;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
48
|
+
if (process.env.DISABLE_CSS_DEDUPE === '1') return;
|
|
49
|
+
|
|
50
|
+
nitroApp.hooks.hook('render:html', async (html: { head: string[] }) => {
|
|
51
|
+
const head = html.head;
|
|
52
|
+
if (!head?.length) return;
|
|
53
|
+
|
|
54
|
+
const inlinedNormalized = collectInlinedCss(head);
|
|
55
|
+
if (!inlinedNormalized) return;
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < head.length; i++) {
|
|
58
|
+
const part = head[i];
|
|
59
|
+
if (!part.includes('rel="stylesheet"')) continue;
|
|
60
|
+
|
|
61
|
+
const tags = part.match(STYLESHEET_LINK_RE);
|
|
62
|
+
if (!tags) continue;
|
|
63
|
+
|
|
64
|
+
let next = part;
|
|
65
|
+
for (const tag of tags) {
|
|
66
|
+
const href = tag.match(HREF_RE)?.[1];
|
|
67
|
+
if (!href || !href.startsWith(BUILD_ASSETS_DIR)) continue;
|
|
68
|
+
|
|
69
|
+
const rules = await loadCssRules(nitroApp, href);
|
|
70
|
+
if (!rules || !rules.length) continue;
|
|
71
|
+
|
|
72
|
+
next = isCssFullyInlined(rules, inlinedNormalized)
|
|
73
|
+
? next.replace(tag, '')
|
|
74
|
+
: next.replace(tag, makeStylesheetAsync(tag));
|
|
75
|
+
}
|
|
76
|
+
head[i] = next;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// При старте сервера выполняет начальную загрузку данных сайта (SiteDataService)
|
|
2
|
+
// — языки, конфиг и т.п., чтобы они были готовы к первому запросу.
|
|
3
|
+
import { SiteDataService } from '../../services/site-data.service';
|
|
4
|
+
|
|
5
|
+
export default defineNitroPlugin(async () => {
|
|
6
|
+
console.log('Server started!');
|
|
7
|
+
|
|
8
|
+
/* Асинхронные действия при старте сервера */
|
|
9
|
+
await Promise.all([
|
|
10
|
+
SiteDataService.fetchSiteData.bind(SiteDataService)(),
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
console.log('Initial actions successfully completed!');
|
|
14
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Инициализирует Sentry на сервере (включён только при заданном DSN и не в dev)
|
|
2
|
+
// и отправляет в него необработанные ошибки Nitro через хук `error`.
|
|
3
|
+
// В мягком режиме — 10% трейсов, в полном (SentryFullCollectionEnabled) — 100%.
|
|
4
|
+
import * as Sentry from '@sentry/nuxt';
|
|
5
|
+
|
|
6
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
7
|
+
const { featureFlags } = useRuntimeConfig().public;
|
|
8
|
+
|
|
9
|
+
Sentry.init({
|
|
10
|
+
dsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
|
|
11
|
+
enabled: !!process.env.NUXT_PUBLIC_SENTRY_DSN && process.env.NODE_ENV !== 'development',
|
|
12
|
+
tracesSampleRate: featureFlags.SentryFullCollectionEnabled ? 1.0 : 0.1,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
nitroApp.hooks.hook('error', (error) => {
|
|
16
|
+
Sentry.captureException(error);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { SiteDataService } from '../../services/site-data.service';
|
|
2
|
+
|
|
3
|
+
export default defineTask({
|
|
4
|
+
meta: {
|
|
5
|
+
name: 'siteInfo:update',
|
|
6
|
+
},
|
|
7
|
+
async run() {
|
|
8
|
+
try {
|
|
9
|
+
await SiteDataService.fetchSiteData.bind(SiteDataService)();
|
|
10
|
+
console.log(`Updated site info: ${new Date().toISOString()}`);
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
result: true,
|
|
14
|
+
};
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return {
|
|
17
|
+
result: error,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
normalizeCss,
|
|
4
|
+
splitCssRules,
|
|
5
|
+
collectInlinedCss,
|
|
6
|
+
isCssFullyInlined,
|
|
7
|
+
makeStylesheetAsync,
|
|
8
|
+
} from '../css-dedupe';
|
|
9
|
+
|
|
10
|
+
describe('normalizeCss', () => {
|
|
11
|
+
it('схлопывает пробелы и обрезает края', () => {
|
|
12
|
+
expect(normalizeCss(' .a { color : red ; }\n\n')).toBe('.a { color : red ; }');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('splitCssRules', () => {
|
|
17
|
+
it('разбивает на нормализованные правила по }', () => {
|
|
18
|
+
expect(splitCssRules('.a{color:red}\n.b{x:1}')).toEqual(['.a{color:red}', '.b{x:1}']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('отбрасывает пустые фрагменты', () => {
|
|
22
|
+
expect(splitCssRules('.a{color:red}\n\n')).toEqual(['.a{color:red}']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('пустой CSS → []', () => {
|
|
26
|
+
expect(splitCssRules('')).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('collectInlinedCss', () => {
|
|
31
|
+
it('собирает содержимое всех <style> из частей head, нормализованным', () => {
|
|
32
|
+
const head = ['<style>.a{color:red}</style>', '<link rel="stylesheet">', '<style>.b{x:1}</style>'];
|
|
33
|
+
const result = collectInlinedCss(head);
|
|
34
|
+
expect(result).toContain('.a{color:red}');
|
|
35
|
+
expect(result).toContain('.b{x:1}');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('части без <style> игнорируются → пустая строка', () => {
|
|
39
|
+
expect(collectInlinedCss(['<link rel="stylesheet">', '<meta>'])).toBe('');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('isCssFullyInlined', () => {
|
|
44
|
+
const inlined = '.a{color:red} .b{x:1} .c{y:2}';
|
|
45
|
+
|
|
46
|
+
it('true, когда все правила есть в инлайне', () => {
|
|
47
|
+
expect(isCssFullyInlined(['.a{color:red}', '.b{x:1}'], inlined)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('false, когда хотя бы одно правило отсутствует', () => {
|
|
51
|
+
expect(isCssFullyInlined(['.a{color:red}', '.z{q:9}'], inlined)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('false на пустом списке правил', () => {
|
|
55
|
+
expect(isCssFullyInlined([], inlined)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('makeStylesheetAsync', () => {
|
|
60
|
+
it('делает линк неблокирующим и добавляет noscript-фолбэк', () => {
|
|
61
|
+
const tag = '<link rel="stylesheet" href="/_nuxt/a.css">';
|
|
62
|
+
const result = makeStylesheetAsync(tag);
|
|
63
|
+
expect(result).toContain('media="print"');
|
|
64
|
+
expect(result).toContain('onload="this.media=\'all\'"');
|
|
65
|
+
expect(result).toContain(`<noscript>${tag}</noscript>`);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getCategoryProp, getCategorySortLang } from '../lang-prop';
|
|
3
|
+
|
|
4
|
+
describe('getCategoryProp', () => {
|
|
5
|
+
it('CJK-локали → name', () => {
|
|
6
|
+
for (const lang of ['ko', 'ja', 'jp', 'zh', 'cn', 'ch']) {
|
|
7
|
+
expect(getCategoryProp(lang)).toBe('name');
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('прочие локали → title', () => {
|
|
12
|
+
expect(getCategoryProp('en')).toBe('title');
|
|
13
|
+
expect(getCategoryProp('fr')).toBe('title');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('getCategorySortLang', () => {
|
|
18
|
+
it('CJK-локали сортируются по en', () => {
|
|
19
|
+
expect(getCategorySortLang('zh')).toBe('en');
|
|
20
|
+
expect(getCategorySortLang('ko')).toBe('en');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('прочие локали сортируются по себе', () => {
|
|
24
|
+
expect(getCategorySortLang('fr')).toBe('fr');
|
|
25
|
+
expect(getCategorySortLang('en')).toBe('en');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export async function apiFetch(url: string, init?: RequestInit): Promise<any> {
|
|
2
|
+
const headers = { ...(init?.headers as Record<string, string>) };
|
|
3
|
+
delete headers['content-length'];
|
|
4
|
+
delete headers['transfer-encoding'];
|
|
5
|
+
delete headers['connection'];
|
|
6
|
+
delete headers['host'];
|
|
7
|
+
|
|
8
|
+
const method = String(init?.method || 'GET').toUpperCase();
|
|
9
|
+
const isIdempotent = method === 'GET' || method === 'HEAD';
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
return await $fetch(url, {
|
|
13
|
+
method: init?.method as any,
|
|
14
|
+
headers,
|
|
15
|
+
body: init?.body,
|
|
16
|
+
responseType: 'json',
|
|
17
|
+
retry: isIdempotent ? 2 : 0,
|
|
18
|
+
retryDelay: 300,
|
|
19
|
+
retryStatusCodes: [408, 425, 429, 500, 502, 503, 504],
|
|
20
|
+
});
|
|
21
|
+
} catch (error: any) {
|
|
22
|
+
throw createError({
|
|
23
|
+
statusCode: error?.statusCode || 502,
|
|
24
|
+
statusMessage: `API error: ${error?.statusCode || 502} ${error?.statusMessage || error?.message || ''}`,
|
|
25
|
+
data: error?.data || error?.response?._data,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getHeaders } from 'h3';
|
|
2
|
+
import type { H3Event } from 'h3';
|
|
3
|
+
|
|
4
|
+
interface CacheEntry<T> {
|
|
5
|
+
data: T;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Хранилище закэшированных ответов (ключ — URL + язык, значение — данные + время истечения)
|
|
10
|
+
const cache = new Map<string, CacheEntry<unknown>>();
|
|
11
|
+
// Хранилище текущих незавершённых запросов для дедупликации параллельных вызовов
|
|
12
|
+
const inflight = new Map<string, Promise<unknown>>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Формирует ключ кэша с учётом языка из заголовка X-Language.
|
|
16
|
+
*/
|
|
17
|
+
function getCacheKey(url: string, event: H3Event): string {
|
|
18
|
+
const lang = event.node.req.headers['x-language'] || 'en';
|
|
19
|
+
return `${lang}::${url}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Выполняет GET-запрос к API с кэшированием и дедупликацией.
|
|
24
|
+
* @param url — адрес API-эндпоинта
|
|
25
|
+
* @param event — H3-событие текущего запроса (нужно для ServerApiHelper)
|
|
26
|
+
* @param maxAge — время жизни кэша в секундах (по умолчанию 600 = 10 минут)
|
|
27
|
+
*/
|
|
28
|
+
export async function cachedApiFetch<T>(url: string, event: H3Event, maxAge = 600): Promise<T> {
|
|
29
|
+
const cacheKey = getCacheKey(url, event);
|
|
30
|
+
|
|
31
|
+
// Если данные есть в кэше и ещё не протухли — возвращаем сразу
|
|
32
|
+
const cached = cache.get(cacheKey);
|
|
33
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
34
|
+
return cached.data as T;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Если запрос по этому ключу уже летит — не делаем дубль, а ждём тот же промис
|
|
38
|
+
const existing = inflight.get(cacheKey);
|
|
39
|
+
if (existing) {
|
|
40
|
+
return existing as Promise<T>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Запускаем реальный запрос к API
|
|
44
|
+
const promise = apiFetch(url, {
|
|
45
|
+
method: 'GET',
|
|
46
|
+
headers: getHeaders(event) as HeadersInit,
|
|
47
|
+
})
|
|
48
|
+
.then((rawData: any) => {
|
|
49
|
+
const data = (rawData && typeof rawData === 'object' && 'data' in rawData ? rawData.data : rawData) as T;
|
|
50
|
+
if (cache.size > 1000) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
for (const [k, v] of cache) {
|
|
53
|
+
if (v.expiresAt < now) cache.delete(k);
|
|
54
|
+
if (cache.size <= 800) break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
cache.set(cacheKey, { data, expiresAt: Date.now() + maxAge * 1000 });
|
|
58
|
+
return data;
|
|
59
|
+
})
|
|
60
|
+
.finally(() => {
|
|
61
|
+
// Убираем промис из inflight — запрос завершён (успешно или с ошибкой)
|
|
62
|
+
inflight.delete(cacheKey);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Регистрируем промис как «в полёте», чтобы параллельные вызовы подхватили его
|
|
66
|
+
inflight.set(cacheKey, promise);
|
|
67
|
+
return promise;
|
|
68
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Чистая логика дедупа инлайн-CSS (без IO/Nitro) — используется в
|
|
2
|
+
// server/plugins/dedupe-inlined-css.ts и покрыта тестами.
|
|
3
|
+
|
|
4
|
+
/** Каталог собранных ассетов Nuxt — трогаем только `<link>` оттуда. */
|
|
5
|
+
export const BUILD_ASSETS_DIR = '/_nuxt/';
|
|
6
|
+
|
|
7
|
+
/** Матчит теги `<link rel="stylesheet" ...>`. */
|
|
8
|
+
export const STYLESHEET_LINK_RE = /<link\b[^>]*\brel="stylesheet"[^>]*>/gi;
|
|
9
|
+
/** Достаёт href .css-файла из тега `<link>`. */
|
|
10
|
+
export const HREF_RE = /\bhref="([^"]+\.css)"/i;
|
|
11
|
+
const STYLE_TAG_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Нормализует CSS для сравнения: схлопывает пробелы в один и обрезает края.
|
|
15
|
+
* @param css - исходный CSS
|
|
16
|
+
*/
|
|
17
|
+
export const normalizeCss = (css: string): string => css.replace(/\s+/g, ' ').trim();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Разбивает CSS на отдельные нормализованные правила (по `}`). Нужно, потому что
|
|
21
|
+
* слитый чанк содержит CSS нескольких компонентов, и в инлайне эти правила раскиданы
|
|
22
|
+
* по разным `<style>`-блокам — целиком файл как подстрока не находится, а по-правилам — да.
|
|
23
|
+
* @param css - CSS файла
|
|
24
|
+
*/
|
|
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);
|
|
30
|
+
}
|
|
31
|
+
return rules;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Собирает содержимое всех `<style>` из частей `<head>` и возвращает нормализованным.
|
|
36
|
+
* @param headParts - массив строк-частей `<head>` (html.head у Nitro)
|
|
37
|
+
*/
|
|
38
|
+
export const collectInlinedCss = (headParts: string[]): string => {
|
|
39
|
+
let inlined = '';
|
|
40
|
+
for (const part of headParts) {
|
|
41
|
+
if (!part.includes('<style')) continue;
|
|
42
|
+
let match: RegExpExecArray | null;
|
|
43
|
+
STYLE_TAG_RE.lastIndex = 0;
|
|
44
|
+
while ((match = STYLE_TAG_RE.exec(part)) !== null) inlined += match[1] + '\n';
|
|
45
|
+
}
|
|
46
|
+
return normalizeCss(inlined);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Все ли правила файла уже присутствуют в инлайне (тогда render-blocking `<link>` не нужен).
|
|
51
|
+
* @param rules - нормализованные правила файла (см. splitCssRules)
|
|
52
|
+
* @param inlinedNormalized - нормализованный инлайн (см. collectInlinedCss)
|
|
53
|
+
*/
|
|
54
|
+
export const isCssFullyInlined = (rules: string[], inlinedNormalized: string): boolean =>
|
|
55
|
+
rules.length > 0 && rules.every((rule) => inlinedNormalized.includes(rule));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Делает `<link rel=stylesheet>` неблокирующим: `media="print"` снимает render-block,
|
|
59
|
+
* `onload` возвращает `media`, `<noscript>` — фолбэк для клиентов без JS.
|
|
60
|
+
* @param tag - исходный тег `<link>`
|
|
61
|
+
*/
|
|
62
|
+
export const makeStylesheetAsync = (tag: string): string => {
|
|
63
|
+
const asyncTag = tag.replace(/\s*\/?>$/, ' media="print" onload="this.media=\'all\'">');
|
|
64
|
+
return `${asyncTag}<noscript>${tag}</noscript>`;
|
|
65
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// CJK-локали: иероглифика не сортируется/не транслитерируется как латиница,
|
|
2
|
+
// поэтому для них берём другое поле и сортируем по en.
|
|
3
|
+
const CJK_LANGS = ['ko', 'ja', 'jp', 'zh', 'cn', 'ch'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Поле категории для вывода: для CJK-локалей — `name`, иначе — `title`.
|
|
7
|
+
* @param lang - код локали
|
|
8
|
+
*/
|
|
9
|
+
export function getCategoryProp(lang: string): 'name' | 'title' {
|
|
10
|
+
return CJK_LANGS.includes(lang) ? 'name' : 'title';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Локаль для сортировки категорий: для CJK — `en`, иначе сам `lang`.
|
|
15
|
+
* @param lang - код локали
|
|
16
|
+
*/
|
|
17
|
+
export function getCategorySortLang(lang: string): string {
|
|
18
|
+
return CJK_LANGS.includes(lang) ? 'en' : lang;
|
|
19
|
+
}
|