@tuhama/translation-manager 0.4.0 → 0.6.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuhama/translation-manager",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A modern, web-based UI for managing i18n translation files in React and other JavaScript projects.",
5
5
  "author": "Tuhama <tuhama.gh.qlyshi@gmail.com>",
6
6
  "license": "MIT",
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs-extra');
2
2
  const path = require('path');
3
+ const Utilities = require('./Utilities');
3
4
 
4
5
  /**
5
6
  * Interface-like class for managing translation file storage.
@@ -62,19 +63,24 @@ class Storage {
62
63
  /**
63
64
  * Writes a single translation file (or all of them).
64
65
  */
65
- async write(lang, content) {
66
+ async write(lang, content, options = {}) {
66
67
  const localesDir = await this.getLocalesDir();
67
68
  const filePath = path.join(localesDir, `${lang}.json`);
68
- await fs.writeJson(filePath, content, { spaces: 2 });
69
+
70
+ // Sort the content before writing if sorting is enabled (default: true)
71
+ const shouldSort = options.sort !== false;
72
+ const finalContent = shouldSort ? Utilities.sortObject(content) : content;
73
+
74
+ await fs.writeJson(filePath, finalContent, { spaces: 2 });
69
75
  }
70
76
 
71
77
  /**
72
78
  * Writes all translations.
73
79
  */
74
- async writeAll(translations) {
80
+ async writeAll(translations, options = {}) {
75
81
  const languages = Object.keys(translations);
76
82
  for (const lang of languages) {
77
- await this.write(lang, translations[lang]);
83
+ await this.write(lang, translations[lang], options);
78
84
  }
79
85
  }
80
86
  }
@@ -65,14 +65,14 @@ class TranslatorManager {
65
65
  /**
66
66
  * Saves a translation key across all language files.
67
67
  */
68
- async saveTranslation(key, values) {
68
+ async saveTranslation(key, values, options = {}) {
69
69
  const translations = await this.storage.readAll();
70
70
  const languages = Object.keys(translations);
71
71
 
72
72
  for (const lang of languages) {
73
73
  if (values[lang] !== undefined) {
74
74
  lodash.set(translations[lang], key, values[lang]);
75
- await this.storage.write(lang, translations[lang]);
75
+ await this.storage.write(lang, translations[lang], options);
76
76
  }
77
77
  }
78
78
  }
@@ -200,7 +200,7 @@ class TranslatorManager {
200
200
  * Saves multiple translation keys across languages.
201
201
  * @param {Object} data - { lang: { key: value } }
202
202
  */
203
- async saveBulkTranslations(data) {
203
+ async saveBulkTranslations(data, options = {}) {
204
204
  const translations = await this.storage.readAll();
205
205
  const languages = Object.keys(translations);
206
206
 
@@ -209,11 +209,114 @@ class TranslatorManager {
209
209
  for (const key in data[lang]) {
210
210
  lodash.set(translations[lang], key, data[lang][key]);
211
211
  }
212
- await this.storage.write(lang, translations[lang]);
212
+ await this.storage.write(lang, translations[lang], options);
213
213
  }
214
214
  }
215
215
  }
216
216
 
217
+ /**
218
+ * Exports missing translation keys in the format: {"key": {"lang1": "", "lang2": ""}}
219
+ * @param {string} sourceLang - The source language to exclude from missing keys
220
+ * @returns {Object} - Export data with missing keys and empty values for each target language
221
+ */
222
+ async exportMissingKeys(sourceLang = 'en') {
223
+ const { languages, translations, allKeys } = await this.scan();
224
+ const exportData = {};
225
+
226
+ // Get all missing keys across all languages
227
+ const allMissingKeys = new Set();
228
+
229
+ languages.forEach(lang => {
230
+ if (lang === sourceLang) return;
231
+
232
+ allKeys.forEach(key => {
233
+ const val = lodash.get(translations[lang], key);
234
+ if (val === undefined || val === '') {
235
+ allMissingKeys.add(key);
236
+ }
237
+ });
238
+ });
239
+
240
+ // Build export structure: {"key": {"lang1": "", "lang2": ""}}
241
+ Array.from(allMissingKeys).forEach(key => {
242
+ exportData[key] = {};
243
+ languages.forEach(lang => {
244
+ if (lang !== sourceLang) {
245
+ const val = lodash.get(translations[lang], key);
246
+ exportData[key][lang] = (val === undefined || val === '') ? '' : val;
247
+ }
248
+ });
249
+ });
250
+
251
+ return {
252
+ exportData,
253
+ metadata: {
254
+ sourceLang,
255
+ targetLanguages: languages.filter(lang => lang !== sourceLang),
256
+ totalKeys: allMissingKeys.size,
257
+ exportedAt: new Date().toISOString()
258
+ }
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Imports translated keys from the export format back into translation files
264
+ * @param {Object} importData - Data in format {"key": {"lang1": "translation", "lang2": "translation"}}
265
+ * @param {Object} options - Import options (merge strategy, formatting, etc.)
266
+ */
267
+ async importTranslations(importData, options = {}) {
268
+ const {
269
+ overwriteExisting = false,
270
+ skipEmpty = true,
271
+ format = true
272
+ } = options;
273
+
274
+ const translations = await this.storage.readAll();
275
+ const languages = Object.keys(translations);
276
+ const importStats = {
277
+ imported: 0,
278
+ skipped: 0,
279
+ errors: []
280
+ };
281
+
282
+ for (const key in importData) {
283
+ const keyTranslations = importData[key];
284
+
285
+ for (const lang in keyTranslations) {
286
+ const translation = keyTranslations[lang];
287
+
288
+ // Skip if language doesn't exist in project
289
+ if (!languages.includes(lang)) {
290
+ importStats.errors.push(`Language '${lang}' not found in project`);
291
+ continue;
292
+ }
293
+
294
+ // Skip empty translations if configured
295
+ if (skipEmpty && (!translation || translation.trim() === '')) {
296
+ importStats.skipped++;
297
+ continue;
298
+ }
299
+
300
+ // Check if key already has a value
301
+ const existingValue = lodash.get(translations[lang], key);
302
+ if (existingValue && existingValue !== '' && !overwriteExisting) {
303
+ importStats.skipped++;
304
+ continue;
305
+ }
306
+
307
+ // Import the translation
308
+ lodash.set(translations[lang], key, translation);
309
+ importStats.imported++;
310
+ }
311
+ }
312
+
313
+ // Save all updated translations
314
+ const saveOptions = { sort: format };
315
+ await this.storage.writeAll(translations, saveOptions);
316
+
317
+ return importStats;
318
+ }
319
+
217
320
  /**
218
321
  * Saves the current configuration to the config file.
219
322
  */
@@ -1,4 +1,4 @@
1
- const { Translate } = require('@google-cloud/translate').v3;
1
+ const { TranslationServiceClient } = require('@google-cloud/translate');
2
2
 
3
3
  /**
4
4
  * Service for interacting with Google Cloud Translation API v3.
@@ -24,7 +24,7 @@ class GoogleTranslator {
24
24
  clientConfig.keyFilename = this.keyFilename;
25
25
  }
26
26
 
27
- this.translate = new Translate(clientConfig);
27
+ this.client = new TranslationServiceClient(clientConfig);
28
28
  }
29
29
 
30
30
  /**
@@ -52,7 +52,7 @@ class GoogleTranslator {
52
52
  targetLanguageCode: targetLang,
53
53
  };
54
54
 
55
- const [response] = await this.translate.translateText(request);
55
+ const [response] = await this.client.translateText(request);
56
56
  const translations = response.translations.map(t => t.translatedText);
57
57
 
58
58
  if (Array.isArray(text)) {
package/src/server.js CHANGED
@@ -10,7 +10,7 @@ const TranslatorManager = require('./core/TranslatorManager');
10
10
  function startServer(targetDir, port = 3000, config = {}) {
11
11
  const app = express();
12
12
  const manager = new TranslatorManager(targetDir, config);
13
-
13
+
14
14
  app.use(cors());
15
15
  app.use(express.json());
16
16
 
@@ -26,8 +26,9 @@ function startServer(targetDir, port = 3000, config = {}) {
26
26
 
27
27
  app.post('/api/translations', async (req, res) => {
28
28
  try {
29
- const { key, values } = req.body;
30
- await manager.saveTranslation(key, values);
29
+ const { key, values, format = true } = req.body;
30
+ const options = { sort: format };
31
+ await manager.saveTranslation(key, values, options);
31
32
  res.json({ success: true });
32
33
  } catch (err) {
33
34
  res.status(500).json({ error: err.message });
@@ -66,9 +67,26 @@ function startServer(targetDir, port = 3000, config = {}) {
66
67
  app.post('/api/translate', async (req, res) => {
67
68
  try {
68
69
  const { text, targetLang, sourceLang } = req.body;
70
+
71
+ // Check if Google Translate is configured
72
+ if (!manager.config.googleTranslate || !manager.config.googleTranslate.projectId) {
73
+ return res.status(400).json({
74
+ error: 'Google Translate is not configured. Please add your Google Cloud Project ID and key file in Settings.',
75
+ configurationRequired: true
76
+ });
77
+ }
78
+
69
79
  const translatedText = await manager.translateSingle(text, targetLang, sourceLang);
70
80
  res.json({ translatedText });
71
81
  } catch (err) {
82
+ // Check if it's a configuration error
83
+ if (err.message.includes('Google Cloud Project ID is required') ||
84
+ err.message.includes('Google Translate configuration is missing')) {
85
+ return res.status(400).json({
86
+ error: err.message,
87
+ configurationRequired: true
88
+ });
89
+ }
72
90
  res.status(500).json({ error: err.message });
73
91
  }
74
92
  });
@@ -86,23 +104,81 @@ function startServer(targetDir, port = 3000, config = {}) {
86
104
  app.post('/api/bulk-translate/execute', async (req, res) => {
87
105
  try {
88
106
  const { sourceLang } = req.body;
107
+
108
+ // Check if Google Translate is configured
109
+ if (!manager.config.googleTranslate || !manager.config.googleTranslate.projectId) {
110
+ return res.status(400).json({
111
+ error: 'Google Translate is not configured. Please add your Google Cloud Project ID and key file in Settings.',
112
+ configurationRequired: true
113
+ });
114
+ }
115
+
89
116
  const preview = await manager.bulkTranslate(sourceLang || 'en');
90
117
  res.json(preview);
91
118
  } catch (err) {
119
+ // Check if it's a configuration error
120
+ if (err.message.includes('Google Cloud Project ID is required') ||
121
+ err.message.includes('Google Translate configuration is missing')) {
122
+ return res.status(400).json({
123
+ error: err.message,
124
+ configurationRequired: true
125
+ });
126
+ }
92
127
  res.status(500).json({ error: err.message });
93
128
  }
94
129
  });
95
130
 
96
131
  app.post('/api/bulk-save', async (req, res) => {
97
132
  try {
98
- const { data } = req.body;
99
- await manager.saveBulkTranslations(data);
133
+ const { data, format = true } = req.body;
134
+ const options = { sort: format };
135
+ await manager.saveBulkTranslations(data, options);
100
136
  res.json({ success: true });
101
137
  } catch (err) {
102
138
  res.status(500).json({ error: err.message });
103
139
  }
104
140
  });
105
141
 
142
+ app.get('/api/export-missing', async (req, res) => {
143
+ try {
144
+ const { sourceLang = 'en' } = req.query;
145
+ const exportResult = await manager.exportMissingKeys(sourceLang);
146
+
147
+ // Set headers for file download
148
+ res.setHeader('Content-Type', 'application/json');
149
+ res.setHeader('Content-Disposition', `attachment; filename="missing-translations-${new Date().toISOString().split('T')[0]}.json"`);
150
+
151
+ res.json(exportResult.exportData);
152
+ } catch (err) {
153
+ res.status(500).json({ error: err.message });
154
+ }
155
+ });
156
+
157
+ app.post('/api/import-translations', async (req, res) => {
158
+ try {
159
+ const { data, options = {} } = req.body;
160
+
161
+ if (!data || typeof data !== 'object') {
162
+ return res.status(400).json({ error: 'Invalid import data format' });
163
+ }
164
+
165
+ const importStats = await manager.importTranslations(data, options);
166
+ res.json({ success: true, stats: importStats });
167
+ } catch (err) {
168
+ res.status(500).json({ error: err.message });
169
+ }
170
+ });
171
+
172
+ app.get('/api/export-missing/preview', async (req, res) => {
173
+ try {
174
+ const { sourceLang = 'en' } = req.query;
175
+ const exportResult = await manager.exportMissingKeys(sourceLang);
176
+ res.json(exportResult);
177
+ } catch (err) {
178
+ res.status(500).json({ error: err.message });
179
+ }
180
+ });
181
+
106
182
  app.get('/api/config', (req, res) => {
107
183
  res.json(manager.config);
108
184
  });
@@ -0,0 +1 @@
1
+ :root{--bg-color:#0b0e14;--sidebar-bg:#111827cc;--accent-color:#7c3aed;--accent-hover:#6d28d9;--text-color:#f3f4f6;--text-muted:#9ca3af;--border-color:#ffffff14;--glass-bg:#ffffff08;--glass-blur:blur(20px);--green:#10b981;--red:#ef4444;--transition:all .25s ease}*{box-sizing:border-box;margin:0;padding:0}body{background-color:var(--bg-color);color:var(--text-color);-webkit-font-smoothing:antialiased;font-family:Inter,system-ui,-apple-system,sans-serif;line-height:1.6}#root{height:100vh;display:flex}.app-container{flex-direction:column;width:100%;height:100vh;display:flex}.app-body{flex:1;grid-template-columns:300px 1fr;display:grid;overflow:hidden}.app-header{background:var(--sidebar-bg);-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-bottom:1px solid var(--border-color);z-index:100;justify-content:space-between;align-items:center;min-height:70px;padding:16px 24px;display:flex}.header-left{align-items:center;display:flex}.brand-section{align-items:center;gap:16px;display:flex}.header-logo{filter:drop-shadow(0 0 8px #7c3aed66);border-radius:8px;width:36px;height:36px}.app-title{background:linear-gradient(135deg,#a78bfa 0%,#3b82f6 100%);-webkit-text-fill-color:transparent;-webkit-background-clip:text;background-clip:text;margin:0;font-size:1.4rem;font-weight:800}.header-right{align-items:center;display:flex}.header-actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.header-primary-btn{background:linear-gradient(135deg, var(--accent-color) 0%, var(--accent-hover) 100%);color:#fff;cursor:pointer;transition:var(--transition);border:none;border-radius:8px;align-items:center;gap:8px;padding:10px 16px;font-size:.9rem;font-weight:600;display:flex}.header-primary-btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px #7c3aed4d}.header-btn{border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;transition:var(--transition);white-space:nowrap;background:#ffffff0d;border-radius:6px;align-items:center;gap:6px;padding:8px 12px;font-size:.8rem;font-weight:500;display:flex}.header-btn:hover{border-color:var(--accent-color);color:var(--accent-color);background:#ffffff14;transform:translateY(-1px)}.sidebar{background:var(--sidebar-bg);-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-right:1px solid var(--border-color);flex-direction:column;display:flex;overflow:hidden}.sidebar-search{border-bottom:1px solid var(--border-color);padding:20px 16px}.search-input{border:1px solid var(--border-color);color:#fff;width:100%;transition:var(--transition);background:#0003;border-radius:10px;padding:12px 16px;font-size:.95rem}.search-input:focus{border-color:var(--accent-color);outline:none;box-shadow:0 0 0 2px #7c3aed1a}.search-input::placeholder{color:var(--text-muted)}.tree-view{flex:1;padding:16px;overflow-y:auto}.loading-state{color:var(--text-muted);justify-content:center;align-items:center;padding:40px 20px;font-style:italic;display:flex}.dropdown-container{position:relative}.dropdown-menu{background:var(--sidebar-bg);border:1px solid var(--border-color);z-index:1000;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-radius:8px;min-width:200px;margin-top:4px;padding:8px 0;position:absolute;top:100%;left:0;right:0;box-shadow:0 8px 32px #0000004d}.dropdown-item{width:100%;color:var(--text-color);cursor:pointer;transition:var(--transition);text-align:left;background:0 0;border:none;align-items:center;gap:12px;padding:10px 16px;font-size:.9rem;display:flex}.dropdown-item:hover{color:var(--accent-color);background:#7c3aed1a}.item-icon{text-align:center;width:16px;font-size:1rem}.dropdown-divider{background:var(--border-color);height:1px;margin:8px 16px}.dropdown-arrow{color:var(--text-muted);transition:var(--transition);font-size:.8rem}.btn-icon{font-size:1rem}.key-tree{list-style:none}.tree-node{margin-bottom:2px}.tree-children{margin:0;padding:0;list-style:none}.tree-branch-header{cursor:pointer;transition:var(--transition);color:var(--text-color);border-radius:6px;align-items:center;padding:8px 14px;font-size:.9rem;font-weight:500;display:flex}.tree-branch-header:hover{background:#ffffff0d}.tree-branch-header.has-selected-child{color:#a78bfa;background:#7c3aed14}.tree-expand-icon{color:var(--text-muted);transition:var(--transition);text-align:center;width:12px;margin-right:8px;font-size:.8rem}.tree-branch-text{flex:1;align-items:center;gap:8px;display:flex}.tree-branch-count{color:var(--text-muted);font-size:.75rem;font-weight:400}.branch-indicator{margin-left:4px;font-size:.8rem}.branch-indicator.missing{filter:drop-shadow(0 0 3px #ef44444d)}.branch-indicator.unused{opacity:.6}.tree-leaf{cursor:pointer;transition:var(--transition);color:var(--text-muted);border-radius:6px;justify-content:space-between;align-items:center;margin-bottom:2px;padding:8px 14px;font-size:.85rem;display:flex}.tree-leaf.unused{opacity:.5;font-style:italic}.tree-leaf.unused .tree-leaf-text:after{content:" (unused)";opacity:.7;margin-left:4px;font-size:.7rem}.tree-leaf-content{flex:1;align-items:center;display:flex}.tree-leaf-text{white-space:nowrap;text-overflow:ellipsis;flex:1;overflow:hidden}.tree-leaf:hover{color:#fff;background:#ffffff0d}.tree-leaf.active{color:#a78bfa;background:#7c3aed26;font-weight:600}.tree-leaf.incomplete .tree-leaf-text{color:var(--text-muted)}.missing-translation-dot{filter:drop-shadow(0 0 5px #ef444480);margin-left:8px;font-size:.8rem}.delete-icon{opacity:0;color:var(--red);cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:4px;padding:2px 4px;font-size:1.1rem}.tree-leaf:hover .delete-icon{opacity:1}.delete-icon:hover{background:#ef44441a}.editor{justify-content:center;align-items:flex-start;padding:40px;display:flex;overflow-y:auto}.editor-form{width:100%;max-width:800px;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);background:#1f293766;border-radius:20px;padding:40px;box-shadow:0 25px 50px -12px #00000080}.form-group{text-align:left;margin-bottom:24px}.form-group.missing label{color:var(--red)}.form-group.missing input,.form-group.missing textarea{border-color:#ef44444d}.missing-label{margin-left:4px;font-size:.75rem;font-style:italic;font-weight:400}.form-group input,.form-group textarea{border:1px solid var(--border-color);color:#fff;background:#0000004d;border-radius:10px;width:100%;padding:14px;font-family:inherit;font-size:1rem}.form-group textarea{resize:vertical;min-height:100px}.form-actions{gap:16px;margin-top:32px;display:flex}.primary-btn{background:var(--accent-color);color:#fff;cursor:pointer;transition:var(--transition);border:none;border-radius:10px;padding:14px 28px;font-weight:600}.primary-btn:hover{background:var(--accent-hover);transform:translateY(-1px)}.secondary-btn{border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;background:0 0;border-radius:10px;padding:14px 28px;font-weight:600}.empty-state{text-align:center;max-width:500px}.hero-icon{filter:drop-shadow(0 0 20px #8b5cf64d);margin-bottom:24px;font-size:4rem}.empty-state h2{margin-bottom:12px;font-size:2rem}.empty-state p{color:var(--text-muted);margin-bottom:32px}.modal-overlay{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);z-index:1000;background:#000000b3;justify-content:center;align-items:center;padding:20px;display:flex;position:fixed;inset:0}.modal-content{border:1px solid var(--border-color);background:#1f2937;border-radius:20px;flex-direction:column;width:100%;max-width:600px;max-height:80vh;display:flex;overflow:hidden;box-shadow:0 25px 50px -12px #00000080}.modal-header{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:center;padding:24px;display:flex}.modal-body{flex:1;padding:24px;overflow-y:auto}.cleanup-item{cursor:pointer;background:#ffffff08;border-radius:10px;align-items:center;gap:12px;margin-bottom:8px;padding:12px;display:flex}.cleanup-item:hover{background:#ffffff0f}.cleanup-item input{accent-color:var(--accent-color)}.maybe-used-warning{color:#f59e0b;align-items:center;gap:4px;margin-left:auto;font-size:.8rem;font-style:italic;display:flex}.modal-footer{border-top:1px solid var(--border-color);justify-content:flex-end;gap:12px;padding:20px;display:flex}.editor-header-row{justify-content:space-between;align-items:center;margin-bottom:32px;display:flex}.magic-btn{color:#a78bfa;background:#7c3aed1a;border-color:#7c3aed4d;padding:10px 16px;font-size:.85rem}.magic-btn:hover{background:var(--accent-color);color:#fff}.label-row{justify-content:space-between;align-items:center;margin-bottom:8px;display:flex}.label-row label{margin-bottom:0}.icon-btn{cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:4px;display:flex}.icon-btn:hover{background:#ffffff1a}.magic-wand{opacity:.6;font-size:1.1rem}.form-group:hover .magic-wand{opacity:1}.alert{border-radius:10px;margin-bottom:20px;padding:12px 16px;font-size:.9rem}.alert-success{color:#34d399;background:#10b9811a;border:1px solid #10b9814d}.alert-error{color:#f87171;background:#ef44441a;border:1px solid #ef44444d}.wizard-step{margin-bottom:24px}.wizard-step label{color:var(--text-color);margin-bottom:12px;font-weight:600;display:block}.source-select{border:1px solid var(--border-color);color:#fff;background:#0000004d;border-radius:10px;width:100%;padding:12px;font-size:1rem}.report-summary{border:1px solid var(--border-color);background:#0003;border-radius:12px;padding:16px}.report-summary ul{color:var(--text-muted);margin-top:12px;padding-left:20px}.preview-container{border:1px solid var(--border-color);background:#0003;border-radius:12px;max-height:400px;padding:16px;overflow-y:auto}.lang-preview{margin-bottom:24px}.lang-preview h3{color:var(--accent-color);border-bottom:1px solid var(--border-color);margin-bottom:12px;padding-bottom:4px;font-size:.85rem}.preview-list{flex-direction:column;gap:8px;display:flex}.preview-item{gap:8px;font-size:.85rem;display:flex}.preview-key{color:var(--text-muted);font-weight:500}.preview-value{color:var(--text-color)}.settings-input{letter-spacing:2px}.help-text{color:var(--text-muted);margin-top:8px;font-size:.8rem}.help-text a{color:var(--accent-color);text-decoration:none}.help-text a:hover{text-decoration:underline}.success-text{color:var(--green);font-weight:500}.approve-btn{background:var(--green)}.approve-btn:hover{background:#059669}.tool-btn.warning{color:#f59e0b;background:#f59e0b1a;border-color:#f59e0b4d}.tool-btn.warning:hover{color:#fff;background:#f59e0b}.missing-keys-list{margin-top:16px;list-style:none}.missing-key-item{border:1px solid var(--border-color);background:#ffffff08;border-radius:12px;justify-content:space-between;align-items:center;margin-bottom:8px;padding:14px;display:flex}.key-path{color:#a78bfa;font-family:JetBrains Mono,Fira Code,monospace;font-size:.85rem}.warning-box{color:#f59e0b;background:#f59e0b14;border:1px solid #f59e0b33;border-radius:12px;margin-bottom:24px;padding:16px;font-size:.85rem}.sm{padding:8px 14px;font-size:.8rem}