itube-specs 0.0.939 → 0.0.941
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/README.md +4 -2
- package/assets/scss/themes/dark/_theme.scss +1 -0
- package/assets/scss/themes/light/_theme.scss +1 -0
- package/components/filter/chips.vue +33 -1
- package/components/filter/model.vue +7 -3
- package/components/section/nav-links.vue +5 -1
- package/components/ui/pagination.vue +9 -0
- package/components/view/model-videos.vue +1 -1
- package/components/view/models.vue +1 -1
- package/components/view/video.vue +1 -1
- package/components/view/videos.vue +1 -1
- package/components/view/webcams.vue +7 -7
- package/composables/__tests__/use-model-filter-chips.test.ts +36 -0
- package/composables/fetch/use-fetch-playlists-by-id.ts +2 -1
- package/composables/fetch/use-fetch-playlists-get-videos-by-id.ts +2 -1
- package/composables/use-filter-pending.ts +4 -2
- package/composables/use-model-filter-chips.ts +24 -2
- package/i18n/locales/de-DE.json +34 -0
- package/i18n/locales/en-US.json +38 -0
- package/i18n/locales/es-ES.json +34 -0
- package/i18n/locales/fr-FR.json +34 -0
- package/i18n/locales/it-IT.json +34 -0
- package/i18n/locales/ja-JP.json +34 -0
- package/i18n/locales/ko-KR.json +34 -0
- package/i18n/locales/ru-RU.json +34 -0
- package/i18n/locales/zh-CN.json +34 -0
- package/nuxt.config.ts +17 -0
- package/package.json +7 -1
- package/pages/channels/[channel].vue +1 -1
- package/scripts/i18n-check.mjs +197 -0
- package/scripts/i18n-sheet.mjs +258 -0
- package/types/filter-chip-item.d.ts +4 -0
- package/utils/ui-pagination/__tests__/get-pagination-link.test.ts +8 -3
- package/utils/ui-pagination/get-pagination-link.ts +11 -3
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"navigation": {
|
|
3
|
+
"main": "主页",
|
|
4
|
+
"videos": "视频",
|
|
5
|
+
"categories": "类别",
|
|
6
|
+
"top_categories": "热门分类",
|
|
7
|
+
"channels": "频道",
|
|
8
|
+
"models": "模特",
|
|
9
|
+
"moments": "时刻",
|
|
10
|
+
"playlists": "播放列表",
|
|
11
|
+
"network": "网络",
|
|
12
|
+
"profile": "个人资料",
|
|
13
|
+
"settings": "设置",
|
|
14
|
+
"my_playlists": "我的播放列表",
|
|
15
|
+
"back_to_main_page": "返回主页",
|
|
16
|
+
"history": "历史记录",
|
|
17
|
+
"live_sex": "直播",
|
|
18
|
+
"watch_later": "稍后观看"
|
|
19
|
+
},
|
|
20
|
+
"sort": {
|
|
21
|
+
"network": "网络",
|
|
22
|
+
"popular": "热门",
|
|
23
|
+
"trending": "趋势",
|
|
24
|
+
"new_videos": "新视频",
|
|
25
|
+
"new": "新",
|
|
26
|
+
"new_short": "新",
|
|
27
|
+
"recommended": "推荐",
|
|
28
|
+
"az": "A-Z",
|
|
29
|
+
"most_videos": "最多视频",
|
|
30
|
+
"last_updated": "最后更新",
|
|
31
|
+
"longest": "最长",
|
|
32
|
+
"longest_short": "最长"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
1
2
|
import { fileURLToPath } from 'node:url';
|
|
2
3
|
import { defineNuxtConfig } from 'nuxt/config';
|
|
3
4
|
|
|
5
|
+
// Общие для всех сайтов переводы (навигация, сортировки, UI-действия). `@nuxtjs/i18n`
|
|
6
|
+
// мержит локали слоёв: файл слоя встаёт в `files` ПЕРЕД проектным, поэтому проект
|
|
7
|
+
// перекрывает любой ключ, а `deepCopy` мержит вложенные группы, не заменяя их целиком.
|
|
8
|
+
// Список кодов выводится из самих файлов, поэтому набор здесь — канонический:
|
|
9
|
+
// код, которого нет в locales приложения, добавит приложению лишнюю локаль.
|
|
10
|
+
const sharedLocalesDir = fileURLToPath(new URL('./i18n/locales', import.meta.url));
|
|
11
|
+
const sharedLocales = readdirSync(sharedLocalesDir)
|
|
12
|
+
.filter(file => file.endsWith('.json'))
|
|
13
|
+
.sort()
|
|
14
|
+
.map(file => ({ code: file.split('-')[0], file }));
|
|
15
|
+
|
|
4
16
|
export default defineNuxtConfig({
|
|
5
17
|
modules: ['@nuxt/eslint', '@nuxt/icon', '@nuxtjs/i18n'],
|
|
6
18
|
imports: {
|
|
@@ -20,6 +32,11 @@ export default defineNuxtConfig({
|
|
|
20
32
|
}
|
|
21
33
|
]
|
|
22
34
|
},
|
|
35
|
+
i18n: {
|
|
36
|
+
// Строго относительный путь: абсолютный не работает в проде (модуль об этом warn'ит).
|
|
37
|
+
langDir: 'locales/',
|
|
38
|
+
locales: sharedLocales,
|
|
39
|
+
},
|
|
23
40
|
icon: {
|
|
24
41
|
customCollections: [
|
|
25
42
|
{
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "itube-specs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.941",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"check:themes": "node scripts/check-themes.mjs",
|
|
15
15
|
"check:styles": "node scripts/check-styles.mjs",
|
|
16
16
|
"check:breakpoints": "node scripts/check-breakpoints.mjs",
|
|
17
|
+
"i18n:check": "node scripts/i18n-check.mjs",
|
|
18
|
+
"i18n:sheet:export": "node scripts/i18n-sheet.mjs export",
|
|
19
|
+
"i18n:sheet:import": "node scripts/i18n-sheet.mjs import",
|
|
17
20
|
"test": "NODE_OPTIONS='--no-warnings' vitest",
|
|
18
21
|
"prepare": "husky"
|
|
19
22
|
},
|
|
@@ -74,6 +77,9 @@
|
|
|
74
77
|
"assets/scss/base/",
|
|
75
78
|
"assets/scss/layout/",
|
|
76
79
|
"assets/scss/main.scss",
|
|
80
|
+
"i18n/locales/",
|
|
81
|
+
"scripts/i18n-check.mjs",
|
|
82
|
+
"scripts/i18n-sheet.mjs",
|
|
77
83
|
"nuxt.config.ts",
|
|
78
84
|
"DOCS/"
|
|
79
85
|
],
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Отчёт о полноте переводов: сравнивает каждую локаль с эталоном en-US.
|
|
2
|
+
// Вне общего lint-флоу — гоняется вручную. Exit 1, если есть рассинхрон (для удобства проверки).
|
|
3
|
+
//
|
|
4
|
+
// в слое: npm run i18n:check
|
|
5
|
+
// в проекте: node node_modules/itube-specs/scripts/i18n-check.mjs
|
|
6
|
+
//
|
|
7
|
+
// Пути берутся от cwd, поэтому один и тот же скрипт обслуживает и слой (общие ключи),
|
|
8
|
+
// и приложение (свои ключи). В приложении общие локали слоя подмешиваются как
|
|
9
|
+
// «унаследованные»: ключ, который есть в слое, не считается отсутствующим, а ключ,
|
|
10
|
+
// продублированный в проекте с тем же значением, репортится как мёртвый оверрайд.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const LOCALES_DIR = path.resolve(process.cwd(), 'i18n', 'locales');
|
|
16
|
+
const BASE = 'en-US';
|
|
17
|
+
|
|
18
|
+
if (!fs.existsSync(LOCALES_DIR)) {
|
|
19
|
+
console.error(`✗ Нет каталога ${path.relative(process.cwd(), LOCALES_DIR)} — запускать из корня слоя или приложения.`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Локали слоя видны приложению через node_modules; при локальной разработке это симлинк
|
|
24
|
+
// на сам слой, поэтому сверяем realpath — иначе слой подмешал бы себе свои же ключи.
|
|
25
|
+
function resolveSharedDir() {
|
|
26
|
+
const dir = path.resolve(process.cwd(), 'node_modules', 'itube-specs', 'i18n', 'locales');
|
|
27
|
+
if (!fs.existsSync(dir)) return null;
|
|
28
|
+
if (fs.realpathSync(dir) === fs.realpathSync(LOCALES_DIR)) return null;
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SHARED_DIR = resolveSharedDir();
|
|
33
|
+
|
|
34
|
+
function flatten(obj, prefix = '', acc = {}) {
|
|
35
|
+
for (const key in obj) {
|
|
36
|
+
const full = prefix ? `${prefix}.${key}` : key;
|
|
37
|
+
const value = obj[key];
|
|
38
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
39
|
+
flatten(value, full, acc);
|
|
40
|
+
} else {
|
|
41
|
+
acc[full] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return acc;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readFlat(dir, file) {
|
|
48
|
+
const full = path.join(dir, file);
|
|
49
|
+
if (!fs.existsSync(full)) return {};
|
|
50
|
+
return flatten(JSON.parse(fs.readFileSync(full, 'utf8')));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const readKeys = (dir, file) => Object.keys(readFlat(dir, file));
|
|
54
|
+
|
|
55
|
+
const ownBaseKeys = readKeys(LOCALES_DIR, `${BASE}.json`);
|
|
56
|
+
const sharedBaseKeys = SHARED_DIR ? readKeys(SHARED_DIR, `${BASE}.json`) : [];
|
|
57
|
+
const baseKeys = [...new Set([...sharedBaseKeys, ...ownBaseKeys])];
|
|
58
|
+
const baseSet = new Set(baseKeys);
|
|
59
|
+
|
|
60
|
+
const locales = fs.readdirSync(LOCALES_DIR)
|
|
61
|
+
.filter(f => f.endsWith('.json') && f !== `${BASE}.json`)
|
|
62
|
+
.sort();
|
|
63
|
+
|
|
64
|
+
const sharedNote = SHARED_DIR ? ` (своих ${ownBaseKeys.length} + общих из слоя ${sharedBaseKeys.length})` : '';
|
|
65
|
+
console.log(`Эталон ${BASE}: ${baseKeys.length} ключей${sharedNote}\n`);
|
|
66
|
+
|
|
67
|
+
let hasGap = false;
|
|
68
|
+
// По умолчанию печатаем все ключи; ограничить можно флагом `--limit=N`.
|
|
69
|
+
const limitArg = process.argv.find(a => a.startsWith('--limit='));
|
|
70
|
+
const SHOW = limitArg ? Number(limitArg.split('=')[1]) : Infinity;
|
|
71
|
+
|
|
72
|
+
// `*_footer_links.*` — это карты «подпись→URL», где сам ключ локализован
|
|
73
|
+
// (en: twinks, ru: твинки, zh: 伪娘). Разные ключи по локалям тут by design,
|
|
74
|
+
// сравнивать их с эталоном бессмысленно — исключаем (с пометкой сколько пропущено).
|
|
75
|
+
const IGNORE = /footer_links\./;
|
|
76
|
+
|
|
77
|
+
const deadOverrides = [];
|
|
78
|
+
|
|
79
|
+
for (const file of locales) {
|
|
80
|
+
const locale = file.replace('.json', '');
|
|
81
|
+
const own = readFlat(LOCALES_DIR, file);
|
|
82
|
+
const shared = SHARED_DIR ? readFlat(SHARED_DIR, file) : {};
|
|
83
|
+
const set = new Set([...Object.keys(shared), ...Object.keys(own)]);
|
|
84
|
+
|
|
85
|
+
const dead = Object.keys(own).filter(k => k in shared && own[k] === shared[k]);
|
|
86
|
+
if (dead.length) deadOverrides.push([locale, dead]);
|
|
87
|
+
|
|
88
|
+
const allMissing = baseKeys.filter(k => !set.has(k));
|
|
89
|
+
const allExtra = [...set].filter(k => !baseSet.has(k));
|
|
90
|
+
const missing = allMissing.filter(k => !IGNORE.test(k));
|
|
91
|
+
const extra = allExtra.filter(k => !IGNORE.test(k));
|
|
92
|
+
const skipped = (allMissing.length - missing.length) + (allExtra.length - extra.length);
|
|
93
|
+
const inherited = Object.keys(shared).filter(k => !(k in own)).length;
|
|
94
|
+
|
|
95
|
+
const notes = [
|
|
96
|
+
skipped ? `пропущено ${skipped} ключей-подписей footer_links` : null,
|
|
97
|
+
inherited ? `наследует ${inherited} из слоя` : null,
|
|
98
|
+
].filter(Boolean);
|
|
99
|
+
|
|
100
|
+
if (!missing.length && !extra.length) {
|
|
101
|
+
console.log(`✓ ${locale} — полная${notes.length ? ` (${notes.join(', ')})` : ''}`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
hasGap = true;
|
|
106
|
+
console.log(`✗ ${locale} — отсутствует: ${missing.length}, лишних: ${extra.length}${notes.length ? `, ${notes.join(', ')}` : ''}`);
|
|
107
|
+
if (missing.length) {
|
|
108
|
+
console.log(` отсутствуют (есть в ${BASE}):`);
|
|
109
|
+
missing.slice(0, SHOW).forEach(k => console.log(` - ${k}`));
|
|
110
|
+
if (missing.length > SHOW) console.log(` … ещё ${missing.length - SHOW}`);
|
|
111
|
+
}
|
|
112
|
+
if (extra.length) {
|
|
113
|
+
console.log(` лишние (нет в ${BASE}):`);
|
|
114
|
+
extra.slice(0, SHOW).forEach(k => console.log(` + ${k}`));
|
|
115
|
+
if (extra.length > SHOW) console.log(` … ещё ${extra.length - SHOW}`);
|
|
116
|
+
}
|
|
117
|
+
console.log('');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- мёртвые оверрайды: значение в проекте совпадает со слоем, ключ можно удалить.
|
|
121
|
+
// Направление мержа одно (проект сильнее слоя), поэтому такой дубль ничего не меняет,
|
|
122
|
+
// но замораживает перевод: правка в слое до этой локали уже не доедет.
|
|
123
|
+
if (SHARED_DIR) {
|
|
124
|
+
console.log('\n— Оверрайды общих ключей —\n');
|
|
125
|
+
const baseDead = (() => {
|
|
126
|
+
const own = readFlat(LOCALES_DIR, `${BASE}.json`);
|
|
127
|
+
const shared = readFlat(SHARED_DIR, `${BASE}.json`);
|
|
128
|
+
return Object.keys(own).filter(k => k in shared && own[k] === shared[k]);
|
|
129
|
+
})();
|
|
130
|
+
if (baseDead.length) deadOverrides.unshift([BASE, baseDead]);
|
|
131
|
+
|
|
132
|
+
if (!deadOverrides.length) {
|
|
133
|
+
console.log('✓ мёртвых оверрайдов нет');
|
|
134
|
+
} else {
|
|
135
|
+
for (const [locale, keys] of deadOverrides) {
|
|
136
|
+
console.log(`✗ ${locale} — дублирует слой без изменений: ${keys.length}`);
|
|
137
|
+
keys.slice(0, SHOW).forEach(k => console.log(` = ${k}`));
|
|
138
|
+
if (keys.length > SHOW) console.log(` … ещё ${keys.length - SHOW}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- SEO keyword-links: каждый keyword из `*_footer_links` должен встречаться
|
|
144
|
+
// в парном `*_text` (иначе ссылка не отрендерится). Совпадение — регистро-
|
|
145
|
+
// независимая подстрока, как в splitTextByKeywordLinks. Пара: хвост
|
|
146
|
+
// `footer_links` → `footer_text` (footer_links↔footer_text, models_footer_links↔models_footer_text).
|
|
147
|
+
console.log('\n— SEO keyword-links (keyword ⊂ парный текст) —\n');
|
|
148
|
+
|
|
149
|
+
function readRaw(file) {
|
|
150
|
+
return JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, file), 'utf8'));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let hasDeadLinks = false;
|
|
154
|
+
let checkedLinks = 0;
|
|
155
|
+
for (const file of [`${BASE}.json`, ...locales]) {
|
|
156
|
+
const locale = file.replace('.json', '');
|
|
157
|
+
const json = readRaw(file);
|
|
158
|
+
const linkKeys = Object.keys(json).filter(
|
|
159
|
+
k => /footer_links$/.test(k) && json[k] && typeof json[k] === 'object' && !Array.isArray(json[k]),
|
|
160
|
+
);
|
|
161
|
+
if (!linkKeys.length) continue;
|
|
162
|
+
checkedLinks++;
|
|
163
|
+
|
|
164
|
+
const problems = [];
|
|
165
|
+
for (const lk of linkKeys) {
|
|
166
|
+
const textKey = lk.replace(/footer_links$/, 'footer_text');
|
|
167
|
+
const text = json[textKey];
|
|
168
|
+
if (typeof text !== 'string') {
|
|
169
|
+
problems.push(`${lk}: нет парного текста "${textKey}"`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const haystack = text.toLowerCase();
|
|
173
|
+
const dead = Object.keys(json[lk]).filter(kw => !haystack.includes(kw.toLowerCase()));
|
|
174
|
+
if (dead.length) problems.push(`${lk} (текст "${textKey}"): нет в тексте — ${dead.map(k => `"${k}"`).join(', ')}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!problems.length) {
|
|
178
|
+
console.log(`✓ ${locale}`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
hasDeadLinks = true;
|
|
182
|
+
console.log(`✗ ${locale}`);
|
|
183
|
+
problems.forEach(p => console.log(` ${p}`));
|
|
184
|
+
}
|
|
185
|
+
if (!checkedLinks) console.log('— нет ключей *_footer_links (SEO-карты живут в приложении)');
|
|
186
|
+
|
|
187
|
+
const hasDeadOverrides = deadOverrides.length > 0;
|
|
188
|
+
if (hasGap || hasDeadLinks || hasDeadOverrides) {
|
|
189
|
+
const parts = [
|
|
190
|
+
hasGap && 'рассинхрон ключей с эталоном',
|
|
191
|
+
hasDeadOverrides && 'мёртвые оверрайды общих ключей',
|
|
192
|
+
hasDeadLinks && 'мёртвые keyword-ссылки',
|
|
193
|
+
].filter(Boolean);
|
|
194
|
+
console.log(`\nЕсть проблемы: ${parts.join('; ')}. Это отчёт, не гейт — он не блокирует коммиты и CI.`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
console.log('\nВсе локали совпадают с эталоном, keyword-ссылки целы.');
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Мост «переводы ↔ CSV» для работы через переводчика.
|
|
2
|
+
//
|
|
3
|
+
// в слое: npm run i18n:sheet:export / npm run i18n:sheet:import
|
|
4
|
+
// в проекте: node node_modules/itube-specs/scripts/i18n-sheet.mjs export|import
|
|
5
|
+
//
|
|
6
|
+
// Пути берутся от cwd, поэтому один и тот же скрипт обслуживает два набора:
|
|
7
|
+
// общие ключи слоя (навигация, сортировки, UI-действия) и ключи приложения
|
|
8
|
+
// (SEO-тексты, нишевая лексика). Каждый набор — свой i18n/i18n.csv, эталон свой.
|
|
9
|
+
//
|
|
10
|
+
// Двусторонний флоу:
|
|
11
|
+
// 1. Разработчик добавляет новый ключ в en-US.json (эталон) нужного набора.
|
|
12
|
+
// 2. export → в CSV появляется строка ключа с заполненным en и пустыми
|
|
13
|
+
// клетками остальных локалей (переводчику видно «новое, надо перевести»).
|
|
14
|
+
// 3. Переводчик правит CSV (обычный файл: Excel / Numbers / Google Sheets → «скачать CSV»).
|
|
15
|
+
// 4. import → значения из CSV попадают в *.json.
|
|
16
|
+
//
|
|
17
|
+
// Эталон структуры и порядка ключей — en-US.json. При импорте каждая локаль
|
|
18
|
+
// пересобирается по порядку ключей эталона (files остаются консистентными,
|
|
19
|
+
// «новые ключи сверху» из en-US подхватываются автоматически).
|
|
20
|
+
//
|
|
21
|
+
// В CSV строки сортируются «непереведённое сверху»: сначала ключи, пустые в
|
|
22
|
+
// наибольшем числе локалей, дальше полностью заполненные — переводчику не надо
|
|
23
|
+
// искать TODO по всему файлу. На импорт порядок строк не влияет.
|
|
24
|
+
//
|
|
25
|
+
// `*_footer_links.*` — карты «подпись→URL», где сам ключ локализован по-разному
|
|
26
|
+
// в каждой локали (by design, см. scripts/i18n-check.mjs). В CSV не выгружаются
|
|
27
|
+
// и при импорте берутся как есть из текущего файла локали.
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
|
|
32
|
+
const ROOT = process.cwd();
|
|
33
|
+
const LOCALES_DIR = path.join(ROOT, 'i18n', 'locales');
|
|
34
|
+
const CSV_PATH = path.join(ROOT, 'i18n', 'i18n.csv');
|
|
35
|
+
const BASE = 'en-US';
|
|
36
|
+
const IGNORE = /footer_links\./;
|
|
37
|
+
|
|
38
|
+
if (!fs.existsSync(LOCALES_DIR)) {
|
|
39
|
+
console.error(`✗ Нет каталога ${path.relative(ROOT, LOCALES_DIR)} — запускать из корня слоя или приложения.`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- файлы локалей: эталон первым, дальше по алфавиту (как в i18n-check) ---
|
|
44
|
+
|
|
45
|
+
function localeFiles() {
|
|
46
|
+
const all = fs.readdirSync(LOCALES_DIR).filter(f => f.endsWith('.json')).sort();
|
|
47
|
+
const rest = all.filter(f => f !== `${BASE}.json`);
|
|
48
|
+
return [`${BASE}.json`, ...rest];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const localeCode = file => file.replace('.json', '');
|
|
52
|
+
|
|
53
|
+
function readJson(file) {
|
|
54
|
+
return JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, file), 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- плоские ключи через точку (только строковые листья, без footer_links) ---
|
|
58
|
+
|
|
59
|
+
function flatten(obj, prefix = '', acc = {}) {
|
|
60
|
+
for (const key in obj) {
|
|
61
|
+
const full = prefix ? `${prefix}.${key}` : key;
|
|
62
|
+
const value = obj[key];
|
|
63
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
64
|
+
flatten(value, full, acc);
|
|
65
|
+
} else if (typeof value === 'string' && !IGNORE.test(full)) {
|
|
66
|
+
acc[full] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return acc;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setDeep(obj, dottedKey, value) {
|
|
73
|
+
const parts = dottedKey.split('.');
|
|
74
|
+
let node = obj;
|
|
75
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
76
|
+
const p = parts[i];
|
|
77
|
+
if (typeof node[p] !== 'object' || node[p] === null || Array.isArray(node[p])) node[p] = {};
|
|
78
|
+
node = node[p];
|
|
79
|
+
}
|
|
80
|
+
node[parts[parts.length - 1]] = value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- CSV (RFC 4180): кавычим поля с , " \n ; экранируем внутренние " как "" ---
|
|
84
|
+
|
|
85
|
+
function csvCell(s) {
|
|
86
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toCsv(rows) {
|
|
90
|
+
return '' + rows.map(r => r.map(csvCell).join(',')).join('\r\n') + '\r\n';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Разделитель зависит от локали редактора (Numbers/Excel пишут `;` или таб) —
|
|
94
|
+
// нюхаем по первой строке, считая только вне кавычек.
|
|
95
|
+
function sniffDelimiter(text) {
|
|
96
|
+
const nl = text.search(/\r|\n/);
|
|
97
|
+
const first = nl === -1 ? text : text.slice(0, nl);
|
|
98
|
+
const counts = { ',': 0, ';': 0, '\t': 0 };
|
|
99
|
+
let q = false;
|
|
100
|
+
for (const ch of first) {
|
|
101
|
+
if (ch === '"') q = !q;
|
|
102
|
+
else if (!q && ch in counts) counts[ch]++;
|
|
103
|
+
}
|
|
104
|
+
return [',', ';', '\t'].reduce((best, d) => (counts[d] > counts[best] ? d : best), ',');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseCsv(text) {
|
|
108
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
109
|
+
const delim = sniffDelimiter(text);
|
|
110
|
+
const rows = [];
|
|
111
|
+
let row = [], field = '', inQuotes = false, i = 0;
|
|
112
|
+
const pushField = () => { row.push(field); field = ''; };
|
|
113
|
+
const pushRow = () => { pushField(); rows.push(row); row = []; };
|
|
114
|
+
while (i < text.length) {
|
|
115
|
+
const c = text[i];
|
|
116
|
+
if (inQuotes) {
|
|
117
|
+
if (c === '"') {
|
|
118
|
+
if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
|
|
119
|
+
inQuotes = false; i++; continue;
|
|
120
|
+
}
|
|
121
|
+
field += c; i++; continue;
|
|
122
|
+
}
|
|
123
|
+
if (c === '"') { inQuotes = true; i++; continue; }
|
|
124
|
+
if (c === delim) { pushField(); i++; continue; }
|
|
125
|
+
if (c === '\r') { if (text[i + 1] === '\n') i++; pushRow(); i++; continue; }
|
|
126
|
+
if (c === '\n') { pushRow(); i++; continue; }
|
|
127
|
+
field += c; i++;
|
|
128
|
+
}
|
|
129
|
+
// хвост без завершающего перевода строки
|
|
130
|
+
if (field !== '' || row.length) pushRow();
|
|
131
|
+
return rows.filter(r => r.length > 1 || (r.length === 1 && r[0] !== ''));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- export ---
|
|
135
|
+
|
|
136
|
+
function exportCsv() {
|
|
137
|
+
const files = localeFiles();
|
|
138
|
+
const codes = files.map(localeCode);
|
|
139
|
+
const flat = files.map(f => flatten(readJson(f)));
|
|
140
|
+
|
|
141
|
+
// ключи и их порядок берём из эталона
|
|
142
|
+
const baseKeys = Object.keys(flat[0]);
|
|
143
|
+
|
|
144
|
+
// Непереведённое — наверх: сначала ключи с наибольшим числом пустых локалей,
|
|
145
|
+
// внутри группы порядок эталона сохраняется. Порядок строк на импорт не влияет
|
|
146
|
+
// (там значения берутся по ключу, файл пересобирается в своём порядке).
|
|
147
|
+
const emptyCount = new Map(baseKeys.map(k => [k, flat.slice(1).filter(fl => !fl[k]).length]));
|
|
148
|
+
const sortedKeys = baseKeys
|
|
149
|
+
.map((k, idx) => ({ k, idx }))
|
|
150
|
+
.sort((a, b) => emptyCount.get(b.k) - emptyCount.get(a.k) || a.idx - b.idx)
|
|
151
|
+
.map(e => e.k);
|
|
152
|
+
|
|
153
|
+
const header = ['key', ...codes];
|
|
154
|
+
const rows = [header];
|
|
155
|
+
for (const k of sortedKeys) {
|
|
156
|
+
rows.push([k, ...flat.map(fl => fl[k] ?? '')]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fs.writeFileSync(CSV_PATH, toCsv(rows), 'utf8');
|
|
160
|
+
|
|
161
|
+
const missing = codes.slice(1).map((code, idx) => {
|
|
162
|
+
const fl = flat[idx + 1];
|
|
163
|
+
const empty = baseKeys.filter(k => !fl[k]).length;
|
|
164
|
+
return empty ? `${code}: ${empty}` : null;
|
|
165
|
+
}).filter(Boolean);
|
|
166
|
+
|
|
167
|
+
const todoRows = sortedKeys.filter(k => emptyCount.get(k) > 0).length;
|
|
168
|
+
|
|
169
|
+
console.log(`✓ Выгружено ${baseKeys.length} ключей × ${codes.length} локалей → ${path.relative(ROOT, CSV_PATH)}`);
|
|
170
|
+
if (todoRows) console.log(` Сверху ${todoRows} строк с непереведёнными ключами.`);
|
|
171
|
+
if (missing.length) console.log(` Пустых (нужен перевод): ${missing.join(', ')}`);
|
|
172
|
+
else console.log(' Все локали заполнены.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- import ---
|
|
176
|
+
|
|
177
|
+
function importCsv() {
|
|
178
|
+
if (!fs.existsSync(CSV_PATH)) {
|
|
179
|
+
console.error(`✗ Нет файла ${path.relative(ROOT, CSV_PATH)} — сначала i18n:sheet:export`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
const rows = parseCsv(fs.readFileSync(CSV_PATH, 'utf8'));
|
|
183
|
+
const [header, ...body] = rows;
|
|
184
|
+
if (!header || header[0] !== 'key') {
|
|
185
|
+
console.error('✗ Первый столбец CSV должен называться "key".');
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
const codes = header.slice(1);
|
|
189
|
+
|
|
190
|
+
// ключи-строки эталона задают структуру и порядок
|
|
191
|
+
const baseFlat = flatten(readJson(`${BASE}.json`));
|
|
192
|
+
const orderedKeys = Object.keys(baseFlat);
|
|
193
|
+
|
|
194
|
+
// значения из CSV: { code: { dottedKey: value } }
|
|
195
|
+
const byCode = Object.fromEntries(codes.map(c => [c, {}]));
|
|
196
|
+
for (const r of body) {
|
|
197
|
+
const key = r[0];
|
|
198
|
+
if (!key || IGNORE.test(key)) continue;
|
|
199
|
+
codes.forEach((c, idx) => {
|
|
200
|
+
const v = r[idx + 1] ?? '';
|
|
201
|
+
if (v !== '') byCode[c][key] = v;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const baseSet = new Set(orderedKeys);
|
|
206
|
+
|
|
207
|
+
// Пересобираем файл локали в его СОБСТВЕННОМ порядке ключей (минимальный diff):
|
|
208
|
+
// - строковый лист есть в эталоне → значение из CSV, иначе текущее;
|
|
209
|
+
// - footer_links → как есть;
|
|
210
|
+
// - лист, которого больше нет в эталоне → выкидываем (удаление ключей едет из en);
|
|
211
|
+
// - структура/порядок вложенности сохраняются 1:1.
|
|
212
|
+
function rebuild(cur, csvVals, prefix = '') {
|
|
213
|
+
const out = {};
|
|
214
|
+
for (const k in cur) {
|
|
215
|
+
const full = prefix ? `${prefix}.${k}` : k;
|
|
216
|
+
const v = cur[k];
|
|
217
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
218
|
+
const child = rebuild(v, csvVals, full);
|
|
219
|
+
if (Object.keys(child).length) out[k] = child;
|
|
220
|
+
} else if (typeof v === 'string') {
|
|
221
|
+
if (IGNORE.test(full)) out[k] = v;
|
|
222
|
+
else if (baseSet.has(full)) out[k] = csvVals[full] ?? v;
|
|
223
|
+
} else {
|
|
224
|
+
out[k] = v;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let changed = 0;
|
|
231
|
+
for (const file of localeFiles()) {
|
|
232
|
+
const code = localeCode(file);
|
|
233
|
+
if (!(code in byCode)) continue;
|
|
234
|
+
const current = readJson(file);
|
|
235
|
+
const csvVals = byCode[code];
|
|
236
|
+
|
|
237
|
+
const next = rebuild(current, csvVals);
|
|
238
|
+
// новые ключи эталона, которых ещё нет в локали, — дописываем в конец объекта
|
|
239
|
+
const nextFlat = flatten(next);
|
|
240
|
+
for (const k of orderedKeys) {
|
|
241
|
+
if (!(k in nextFlat) && csvVals[k] !== undefined) setDeep(next, k, csvVals[k]);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const out = JSON.stringify(next, null, 2) + '\n';
|
|
245
|
+
const prev = fs.readFileSync(path.join(LOCALES_DIR, file), 'utf8');
|
|
246
|
+
if (out !== prev) { fs.writeFileSync(path.join(LOCALES_DIR, file), out, 'utf8'); changed++; console.log(` ~ ${file}`); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log(changed ? `✓ Обновлено файлов: ${changed}. Прогони i18n:check.` : '✓ Изменений нет.');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const mode = process.argv[2];
|
|
253
|
+
if (mode === 'export') exportCsv();
|
|
254
|
+
else if (mode === 'import') importCsv();
|
|
255
|
+
else {
|
|
256
|
+
console.error('Использование: node scripts/i18n-sheet.mjs export|import');
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
@@ -9,4 +9,8 @@ export interface IFilterChipItem {
|
|
|
9
9
|
value?: string | string[] | number
|
|
10
10
|
/** Тип фильтра, к которому относится чипса. */
|
|
11
11
|
key?: string
|
|
12
|
+
/** Код страны для флага (`/icons/flags/<код>.svg`) — у языковой чипсы вебкамов.
|
|
13
|
+
* У такой чипсы подпись собрана переводом («Russian speaking»), поэтому
|
|
14
|
+
* капитализация слов к ней не применяется. */
|
|
15
|
+
flag?: string
|
|
12
16
|
}
|
|
@@ -7,9 +7,14 @@ describe('getPaginationLink', () => {
|
|
|
7
7
|
expect(result).toEqual({ path: '/videos', query: { sort: 'new' } });
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
-
it('для прочих страниц сохраняет query и проставляет номер
|
|
10
|
+
it('для прочих страниц сохраняет query и проставляет номер', () => {
|
|
11
11
|
const result = getPaginationLink(5, 'page', '/videos', { sort: 'new' });
|
|
12
|
-
expect(result).toEqual({ query: { sort: 'new', page: 5 } });
|
|
12
|
+
expect(result).toEqual({ path: '/videos', query: { sort: 'new', page: 5 } });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('путь есть у любой страницы: относительную локацию роутер разрешал бы сам', () => {
|
|
16
|
+
const result = getPaginationLink(3, 'page', '/gay/webcams', { filter_language: 'ru' });
|
|
17
|
+
expect(result).toEqual({ path: '/gay/webcams', query: { filter_language: 'ru', page: 3 } });
|
|
13
18
|
});
|
|
14
19
|
|
|
15
20
|
it('зажимает значение < 1 к первой странице', () => {
|
|
@@ -19,6 +24,6 @@ describe('getPaginationLink', () => {
|
|
|
19
24
|
|
|
20
25
|
it('перезаписывает существующий префикс', () => {
|
|
21
26
|
const result = getPaginationLink(7, 'page', '/videos', { page: '2', q: 'x' });
|
|
22
|
-
expect(result).toEqual({ query: { page: 7, q: 'x' } });
|
|
27
|
+
expect(result).toEqual({ path: '/videos', query: { page: 7, q: 'x' } });
|
|
23
28
|
});
|
|
24
29
|
});
|
|
@@ -3,10 +3,17 @@ type QueryRecord = Record<string, QueryParam>;
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Строит объект ссылки для страницы пагинации: для первой страницы убирает параметр `prefix`
|
|
6
|
-
* из query
|
|
6
|
+
* из query, для остальных — проставляет номер. Значение < 1 зажимается к 1.
|
|
7
|
+
*
|
|
8
|
+
* Путь проставляем ВСЕГДА, даже когда он равен текущему. Локация без `path` — относительная,
|
|
9
|
+
* и её разрешает роутер: у нишевых разделов (они живут на алиасах записей) так пропадал
|
|
10
|
+
* префикс ниши, а `href` у ссылки вообще собирается из результата `resolve` — то есть любой
|
|
11
|
+
* промах разрешения уезжает в разметку. Клик мышью при этом работает (навигация идёт по
|
|
12
|
+
* объекту), а «открыть в новой вкладке» — уже по `href`, и открывалось не то.
|
|
13
|
+
*
|
|
7
14
|
* @param value - номер страницы
|
|
8
15
|
* @param prefix - имя query-параметра страницы (напр. `page`)
|
|
9
|
-
* @param path - текущий путь
|
|
16
|
+
* @param path - текущий путь
|
|
10
17
|
* @param query - текущий query
|
|
11
18
|
*/
|
|
12
19
|
export function getPaginationLink(
|
|
@@ -14,7 +21,7 @@ export function getPaginationLink(
|
|
|
14
21
|
prefix: string,
|
|
15
22
|
path: string,
|
|
16
23
|
query: QueryRecord,
|
|
17
|
-
): { path
|
|
24
|
+
): { path: string; query: QueryRecord } {
|
|
18
25
|
const safeValue = Math.max(1, Number(value));
|
|
19
26
|
|
|
20
27
|
if (safeValue === 1) {
|
|
@@ -26,6 +33,7 @@ export function getPaginationLink(
|
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
return {
|
|
36
|
+
path,
|
|
29
37
|
query: {
|
|
30
38
|
...query,
|
|
31
39
|
[prefix]: safeValue,
|