itube-specs 0.0.940 → 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/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 +1 -1
- 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/README.md
CHANGED
|
@@ -183,9 +183,11 @@ ad-конфиг), теперь читается из `runtimeConfig.public` (з
|
|
|
183
183
|
|
|
184
184
|
- **Слой:** компоненты, composables, API-сервисы, доменные утилиты, runtime-константы/хелперы, типы,
|
|
185
185
|
`lib/` (продуктовые данные), `config/` (темы, брейкпоинты), middleware (`auth`/`normalize`/`age`), SSR-плагины
|
|
186
|
-
(`country`/`mobile`/`services`/`sentry`/`adv`), BFF-хендлеры `server/api
|
|
186
|
+
(`country`/`mobile`/`services`/`sentry`/`adv`), BFF-хендлеры `server/api/**`, общие i18n-ключи
|
|
187
|
+
(`i18n/locales/` — навигация, сортировки, UI-обвязка) — всё site-agnostic.
|
|
187
188
|
- **Приложение:** только per-site — **значения** config (флаги/реклама/локали) через `runtimeConfig.public`
|
|
188
|
-
(слой держит пустые контейнеры-заглушки), i18n
|
|
189
|
+
(слой держит пустые контейнеры-заглушки), свои i18n-ключи (SEO/ниша) и оверрайды общих,
|
|
190
|
+
`public/` и **выбор темы** (`theme:` в
|
|
189
191
|
`config/site.config.ts`). Своего SCSS у приложения нет: `assets/scss/` — пустой слот-оверрайд.
|
|
190
192
|
`server/` целиком в слое, включая locale-кластер.
|
|
191
193
|
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
<!-- Пагинация: номера страниц, «Предыдущая»/«Следующая» и путь с текущим query.
|
|
2
|
+
|
|
3
|
+
Через `Lazy…` подключать МОЖНО, но только с `hydrate-on-idle`, не с
|
|
4
|
+
`hydrate-on-visible`. Пагинация стоит внизу страницы, а фильтры и сортировка
|
|
5
|
+
меняют query из первого экрана: пока компонент не гидрирован, в DOM висит
|
|
6
|
+
разметка от серверного рендера, и гидрация её атрибуты не переписывает. Клик
|
|
7
|
+
мышью после этого работает (навигация идёт по объекту `to`), а «открыть в новой
|
|
8
|
+
вкладке» уходило по устаревшему `href` — без выбранных фильтров и сортировки.
|
|
9
|
+
Ключ `:key` от числа страниц спасал только когда их количество менялось. -->
|
|
1
10
|
<template>
|
|
2
11
|
<nav
|
|
3
12
|
v-if="length > 1"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"navigation": {
|
|
3
|
+
"main": "Hauptmenü",
|
|
4
|
+
"videos": "Videos",
|
|
5
|
+
"categories": "Kategorien",
|
|
6
|
+
"top_categories": "Top-Kategorien",
|
|
7
|
+
"channels": "Kanäle",
|
|
8
|
+
"live_sex": "Live-Sex",
|
|
9
|
+
"history": "Verlauf",
|
|
10
|
+
"models": "Models",
|
|
11
|
+
"moments": "Momente",
|
|
12
|
+
"playlists": "Playlists",
|
|
13
|
+
"network": "Netzwerk",
|
|
14
|
+
"profile": "Profil",
|
|
15
|
+
"settings": "Einstellungen",
|
|
16
|
+
"my_playlists": "Meine Playlists",
|
|
17
|
+
"back_to_main_page": "Zur Hauptseite zurück",
|
|
18
|
+
"watch_later": "Später ansehen"
|
|
19
|
+
},
|
|
20
|
+
"sort": {
|
|
21
|
+
"network": "Netzwerk",
|
|
22
|
+
"popular": "Beliebt",
|
|
23
|
+
"trending": "Angesagt",
|
|
24
|
+
"new_videos": "Neue Videos",
|
|
25
|
+
"new": "Neu",
|
|
26
|
+
"new_short": "Neu",
|
|
27
|
+
"recommended": "Empfohlen",
|
|
28
|
+
"az": "A-Z",
|
|
29
|
+
"most_videos": "Meiste Videos",
|
|
30
|
+
"last_updated": "Zuletzt aktualisiert",
|
|
31
|
+
"longest": "Längste",
|
|
32
|
+
"longest_short": "Längste"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"navigation": {
|
|
3
|
+
"main": "Main",
|
|
4
|
+
"videos": "Videos",
|
|
5
|
+
"categories": "Categories",
|
|
6
|
+
"top_categories": "Top categories",
|
|
7
|
+
"channels": "Channels",
|
|
8
|
+
"live_sex": "Live Sex",
|
|
9
|
+
"webcams": "Webcams",
|
|
10
|
+
"history": "History",
|
|
11
|
+
"models": "Models",
|
|
12
|
+
"moments": "Moments",
|
|
13
|
+
"playlists": "Playlists",
|
|
14
|
+
"network": "Network",
|
|
15
|
+
"profile": "Profile",
|
|
16
|
+
"settings": "Settings",
|
|
17
|
+
"my_playlists": "My playlists",
|
|
18
|
+
"library": "Library",
|
|
19
|
+
"back_to_main_page": "Back to Main page",
|
|
20
|
+
"watch_later": "Watch Later"
|
|
21
|
+
},
|
|
22
|
+
"sort": {
|
|
23
|
+
"recent": "Recent",
|
|
24
|
+
"relevant": "Relevant",
|
|
25
|
+
"network": "Network",
|
|
26
|
+
"popular": "Popular",
|
|
27
|
+
"trending": "Trending",
|
|
28
|
+
"new_videos": "New videos",
|
|
29
|
+
"new": "New",
|
|
30
|
+
"new_short": "New",
|
|
31
|
+
"recommended": "Recommended",
|
|
32
|
+
"az": "A-Z",
|
|
33
|
+
"most_videos": "Most videos",
|
|
34
|
+
"last_updated": "Last updated",
|
|
35
|
+
"longest": "Longest",
|
|
36
|
+
"longest_short": "Longest"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"navigation": {
|
|
3
|
+
"main": "Principal",
|
|
4
|
+
"videos": "Vídeos",
|
|
5
|
+
"categories": "Categorías",
|
|
6
|
+
"top_categories": "Categorías principales",
|
|
7
|
+
"channels": "Canales",
|
|
8
|
+
"models": "Modelos",
|
|
9
|
+
"moments": "Momentos",
|
|
10
|
+
"playlists": "Listas de reproducción",
|
|
11
|
+
"network": "Red",
|
|
12
|
+
"profile": "Perfil",
|
|
13
|
+
"settings": "Configuraciones",
|
|
14
|
+
"my_playlists": "Mis listas de reproducción",
|
|
15
|
+
"back_to_main_page": "Volver a la página principal",
|
|
16
|
+
"history": "Historial",
|
|
17
|
+
"live_sex": "Sexo en vivo",
|
|
18
|
+
"watch_later": "Ver después"
|
|
19
|
+
},
|
|
20
|
+
"sort": {
|
|
21
|
+
"network": "Red",
|
|
22
|
+
"popular": "Popular",
|
|
23
|
+
"trending": "Tendencia",
|
|
24
|
+
"new_videos": "Vídeos nuevos",
|
|
25
|
+
"new": "Nuevo",
|
|
26
|
+
"new_short": "Nuevo",
|
|
27
|
+
"recommended": "Recomendado",
|
|
28
|
+
"az": "A-Z",
|
|
29
|
+
"most_videos": "Más videos",
|
|
30
|
+
"last_updated": "Última actualización",
|
|
31
|
+
"longest": "Más largo",
|
|
32
|
+
"longest_short": "Largos"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"navigation": {
|
|
3
|
+
"main": "Principal",
|
|
4
|
+
"videos": "Vidéos",
|
|
5
|
+
"categories": "Catégories",
|
|
6
|
+
"top_categories": "Catégories principales",
|
|
7
|
+
"channels": "Chaînes",
|
|
8
|
+
"models": "Modèles",
|
|
9
|
+
"moments": "Moments",
|
|
10
|
+
"playlists": "Listes de lecture",
|
|
11
|
+
"network": "Réseau",
|
|
12
|
+
"profile": "Profil",
|
|
13
|
+
"settings": "Paramètres",
|
|
14
|
+
"my_playlists": "Mes listes de lecture",
|
|
15
|
+
"back_to_main_page": "Retour à la page principale",
|
|
16
|
+
"history": "Historique",
|
|
17
|
+
"live_sex": "Sexe en direct",
|
|
18
|
+
"watch_later": "Regarder plus tard"
|
|
19
|
+
},
|
|
20
|
+
"sort": {
|
|
21
|
+
"network": "Réseau",
|
|
22
|
+
"popular": "Populaire",
|
|
23
|
+
"trending": "Tendance",
|
|
24
|
+
"new_videos": "Nouvelles vidéos",
|
|
25
|
+
"new": "Nouveau",
|
|
26
|
+
"new_short": "Nouveau",
|
|
27
|
+
"recommended": "Recommandé",
|
|
28
|
+
"az": "A-Z",
|
|
29
|
+
"most_videos": "Plus de vidéos",
|
|
30
|
+
"last_updated": "Dernière mise à jour",
|
|
31
|
+
"longest": "Les plus longues",
|
|
32
|
+
"longest_short": "Longues"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"navigation": {
|
|
3
|
+
"main": "Principale",
|
|
4
|
+
"videos": "Video",
|
|
5
|
+
"categories": "Categorie",
|
|
6
|
+
"top_categories": "Categorie principali",
|
|
7
|
+
"channels": "Canali",
|
|
8
|
+
"models": "Modelli",
|
|
9
|
+
"moments": "Momenti",
|
|
10
|
+
"playlists": "Playlist",
|
|
11
|
+
"network": "Rete",
|
|
12
|
+
"profile": "Profilo",
|
|
13
|
+
"settings": "Impostazioni",
|
|
14
|
+
"my_playlists": "Le mie playlist",
|
|
15
|
+
"back_to_main_page": "Torna alla pagina principale",
|
|
16
|
+
"history": "Cronologia",
|
|
17
|
+
"live_sex": "Sesso dal vivo",
|
|
18
|
+
"watch_later": "Guarda dopo"
|
|
19
|
+
},
|
|
20
|
+
"sort": {
|
|
21
|
+
"network": "Rete",
|
|
22
|
+
"popular": "Popolare",
|
|
23
|
+
"trending": "Tendenza",
|
|
24
|
+
"new_videos": "Nuovi video",
|
|
25
|
+
"new": "Nuovo",
|
|
26
|
+
"new_short": "Nuovo",
|
|
27
|
+
"recommended": "Raccomandato",
|
|
28
|
+
"az": "A-Z",
|
|
29
|
+
"most_videos": "Più video",
|
|
30
|
+
"last_updated": "Ultimo aggiornamento",
|
|
31
|
+
"longest": "Più lungo",
|
|
32
|
+
"longest_short": "Lunghi"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"navigation": {
|
|
3
|
+
"main": "メイン",
|
|
4
|
+
"videos": "ビデオ",
|
|
5
|
+
"top_categories": "トップカテゴリ",
|
|
6
|
+
"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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"navigation": {
|
|
3
|
+
"main": "Главная",
|
|
4
|
+
"videos": "Видео",
|
|
5
|
+
"categories": "Категории",
|
|
6
|
+
"top_categories": "Топ категории",
|
|
7
|
+
"channels": "Каналы",
|
|
8
|
+
"live_sex": "Прямой эфир",
|
|
9
|
+
"history": "История",
|
|
10
|
+
"models": "Модели",
|
|
11
|
+
"moments": "Моменты",
|
|
12
|
+
"playlists": "Плейлисты",
|
|
13
|
+
"network": "Сеть",
|
|
14
|
+
"profile": "Профиль",
|
|
15
|
+
"settings": "Настройки",
|
|
16
|
+
"my_playlists": "Мои плейлисты",
|
|
17
|
+
"back_to_main_page": "Вернуться на главную",
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|