@tuhama/translation-manager 0.7.2 → 0.8.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/README.md CHANGED
@@ -6,52 +6,79 @@ A modern, web-based interface for managing i18n translation files in React and o
6
6
  > This project is **AI-Ready**. AI agents can use the included `translations.skill` to automatically audit and manage your translations.
7
7
 
8
8
  ## Features
9
+
9
10
  - **Modern UI**: Dark mode, glassmorphism, and smooth animations.
10
11
  - **AI-Powered Translation**: Support for **OpenAI (GPT-4o)**, **Google Gemini**, and Google Cloud Translate.
11
12
  - **Context-Aware Scanning**: Extracts code snippets where translation keys are used, providing crucial context for AI translations.
12
13
  - **Missing Keys Detection**: Identifies translation keys used in source code but missing from files.
13
14
  - **Cleanup Tool**: Detects and batch-removes unused translation keys.
14
15
  - **Normalization**: Synchronizes keys across all languages and sorts them alphabetically with one click.
16
+ - **External Namespaces**: Support for directory-based translation storage (e.g., `en/common.json`, `en/auth.json`).
17
+ - **Multiple Namespace Scanning**: Automatically detects multiple `useTranslation` hooks and array-based namespace definitions in a single file.
18
+ - **Deep Nesting**: Supports deep-nested directory structures for complex projects.
15
19
  - **Export/Import**: Export all missing translations to an **AI-Friendly** JSON file (including code context) for external translation.
16
20
  - **CLI Status**: Machine-readable JSON output for project health monitoring.
17
21
  - **Tree View**: Easy navigation and management of translation keys.
18
22
  - **Zero Config**: Auto-detects common locales folders.
19
23
 
20
24
  ## Installation
25
+
21
26
  Add it as a devDependency to your project:
27
+
22
28
  ```bash
23
29
  npm install -g @tuhama/translation-manager
24
30
  ```
31
+
25
32
  Or run directly with npx:
33
+
26
34
  ```bash
27
35
  npx @tuhama/translation-manager
28
36
  ```
29
37
 
30
38
  ## Usage
31
39
 
40
+ ### 📂 External Namespaces (Directory-based)
41
+
42
+ Translation Manager now supports both flat JSON files (e.g., `en.json`) and directory-based namespaces. This is ideal for large projects where you want to split translations into logical modules.
43
+
44
+ **How it works:**
45
+
46
+ - If a folder named `en` exists in your locales directory, the manager will read all `.json` files inside it as namespaces.
47
+ - Nested folders are also supported (e.g., `en/auth/errors.json` -> `en.auth.errors`).
48
+ - When scanning source code, `useTranslation('auth')` will correctly map relative keys to the `auth` namespace.
49
+
32
50
  ### 🤖 AI-Friendly Localization
51
+
33
52
  The manager now extracts the **surrounding code** for every translation key it finds. This context is passed to AI models (like OpenAI or Gemini) to ensure highly accurate translations that respect your code's intent.
34
53
 
35
54
  ### 📤📥 Export/Import (AI-Enhanced)
55
+
36
56
  You can export all missing translation keys to a single JSON file. This export is **AI-Ready**, containing code snippets for each key so you can feed it to an LLM for context-aware translations.
37
57
 
38
58
  ### 📊 CLI Status
59
+
39
60
  Check your translation coverage programmatically:
61
+
40
62
  ```bash
41
63
  npx @tuhama/translation-manager status
42
64
  ```
65
+
43
66
  Outputs a machine-readable JSON summary of missing keys, coverage percentage, and project health.
44
67
 
45
68
  ### ⚠️ Missing Keys Detection
69
+
46
70
  The application automatically scans your source code for translation keys used (e.g., `t('key.name')`) but missing from your translation files. Click the "**Missing**" button in the sidebar to review and create them instantly.
47
71
 
48
72
  ### 🪄 Auto-Translation
73
+
49
74
  Configure Google Cloud, OpenAI, or Gemini in the settings to enable auto-translation. Use the "**Source-to-All**" button in the editor to quickly populate all languages.
50
75
 
51
76
  ## Configuration
77
+
52
78
  You can optionally create a `translation.config.json` in your project root:
53
79
 
54
80
  ### Using OpenAI (Recommended for AI Quality)
81
+
55
82
  ```json
56
83
  {
57
84
  "path": "src/locales",
@@ -64,6 +91,7 @@ You can optionally create a `translation.config.json` in your project root:
64
91
  ```
65
92
 
66
93
  ### Using Google Cloud Translate
94
+
67
95
  ```json
68
96
  {
69
97
  "path": "src/locales",
@@ -74,14 +102,17 @@ You can optionally create a `translation.config.json` in your project root:
74
102
  ```
75
103
 
76
104
  ## Development
105
+
77
106
  To work on this repo:
107
+
78
108
  1. `npm install`
79
109
  2. `cd web && npm install`
80
110
  3. `npm run dev` (starts both the API and the Vite UI)
81
111
 
82
112
  ## Limitations
113
+
83
114
  - **Dynamic Keys**: Highly dynamic keys (e.g. `t(someVar + '.key')`) may not be detected by the "Missing Keys" tool.
84
- - **Namespaces**: Currently optimized for single-namespace or default-namespace projects.
85
115
 
86
116
  ## License
117
+
87
118
  MIT © [Tuhama](mailto:tuhama.gh.qlyshi@gmail.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuhama/translation-manager",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "AI-powered i18n management with context-aware scanning and support for OpenAI, Gemini, and Google Translate.",
5
5
  "author": "Tuhama <tuhama.gh.qlyshi@gmail.com>",
6
6
  "license": "MIT",
@@ -45,44 +45,62 @@ class Scanner {
45
45
  */
46
46
  async findUnusedKeys(allKeys) {
47
47
  const files = await this.getFiles();
48
- const contents = await Promise.all(files.map(f => fs.readFile(f, 'utf-8')));
49
- const combinedContent = contents.join('\n---\n');
50
-
51
48
  const used = new Set();
52
49
  const maybeUsed = new Set();
53
- const unused = [];
54
50
 
55
- allKeys.forEach(key => {
56
- // 1. Literal usage
57
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
- const literalRegex = new RegExp(`['"\`]${escapedKey}['"\`]`, 'g');
51
+ for (const file of files) {
52
+ const content = await fs.readFile(file, 'utf-8');
53
+ const namespaces = this.extractNamespace(content);
59
54
 
60
- if (literalRegex.test(combinedContent)) {
61
- used.add(key);
62
- return;
63
- }
55
+ allKeys.forEach(key => {
56
+ // If key is already marked as used, skip
57
+ if (used.has(key)) return;
64
58
 
65
- // 2. Dynamic usage
66
- const parts = key.split('.');
67
- let isMaybeUsed = false;
59
+ // 1. Literal usage
60
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61
+
62
+ // Check for full key match
63
+ const literalRegex = new RegExp(`['"\`]${escapedKey}['"\`]`, 'g');
64
+ if (literalRegex.test(content)) {
65
+ used.add(key);
66
+ return;
67
+ }
68
68
 
69
- for (let i = 1; i < parts.length; i++) {
70
- const prefix = parts.slice(0, i).join('.') + '.';
71
- const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
- const dynamicRegex = new RegExp("(['\"`]" + escapedPrefix + "['\"`].*?[+])|(['\"`]" + escapedPrefix + ".*?\\${)", 'g');
69
+ // Check for namespaced match if namespaces exist
70
+ for (const namespace of namespaces) {
71
+ if (key.startsWith(namespace + '.')) {
72
+ const relativeKey = key.substring(namespace.length + 1);
73
+ const escapedRelativeKey = relativeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ const relativeRegex = new RegExp(`['"\`]${escapedRelativeKey}['"\`]`, 'g');
75
+ if (relativeRegex.test(content)) {
76
+ used.add(key);
77
+ return;
78
+ }
79
+ }
80
+ }
73
81
 
74
- if (dynamicRegex.test(combinedContent)) {
75
- isMaybeUsed = true;
76
- break;
82
+ // 2. Dynamic usage
83
+ const parts = key.split('.');
84
+ let isMaybeUsed = false;
85
+
86
+ for (let i = 1; i < parts.length; i++) {
87
+ const prefix = parts.slice(0, i).join('.') + '.';
88
+ const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
89
+ const dynamicRegex = new RegExp("(['\"`]" + escapedPrefix + "['\"`].*?[+])|(['\"`]" + escapedPrefix + ".*?\\${)", 'g');
90
+
91
+ if (dynamicRegex.test(content)) {
92
+ isMaybeUsed = true;
93
+ break;
94
+ }
77
95
  }
78
- }
79
96
 
80
- if (isMaybeUsed) {
81
- maybeUsed.add(key);
82
- } else {
83
- unused.push(key);
84
- }
85
- });
97
+ if (isMaybeUsed) {
98
+ maybeUsed.add(key);
99
+ }
100
+ });
101
+ }
102
+
103
+ const unused = allKeys.filter(key => !used.has(key) && !maybeUsed.has(key));
86
104
 
87
105
  return {
88
106
  unused: unused.sort(),
@@ -90,6 +108,33 @@ class Scanner {
90
108
  };
91
109
  }
92
110
 
111
+ /**
112
+ * Extracts all namespaces from useTranslation('ns') or useTranslation(['ns1', 'ns2']) calls.
113
+ */
114
+ extractNamespace(content) {
115
+ const namespaces = new Set();
116
+
117
+ // Match useTranslation('ns') or useTranslations('ns')
118
+ const singleRegex = /\buseTranslations?\(\s*['"\`]([^'"`]+)['"\`]/g;
119
+ let match;
120
+ while ((match = singleRegex.exec(content)) !== null) {
121
+ namespaces.add(match[1]);
122
+ }
123
+
124
+ // Match useTranslation(['ns1', 'ns2'])
125
+ const arrayRegex = /\buseTranslations?\(\s*\[([^\]]+)\]/g;
126
+ while ((match = arrayRegex.exec(content)) !== null) {
127
+ const nsArrayStr = match[1];
128
+ const nsRegex = /['"\`]([^'"`]+)['"\`]/g;
129
+ let nsMatch;
130
+ while ((nsMatch = nsRegex.exec(nsArrayStr)) !== null) {
131
+ namespaces.add(nsMatch[1]);
132
+ }
133
+ }
134
+
135
+ return Array.from(namespaces);
136
+ }
137
+
93
138
  /**
94
139
  * Finds keys that are used in source code but missing from translation files.
95
140
  * Returns an array of keys by default, or an object with context if includeContext is true.
@@ -104,32 +149,35 @@ class Scanner {
104
149
  // 2. i18n.t('key')
105
150
  // 3. i18nKey="key"
106
151
  // 4. <Trans i18nKey="key">
107
- // 5. useTranslation(['namespace']) -> t('key')
108
152
  const patterns = [
109
- /(?:\bt\(|i18n\.t\(|i18nKey=)\s*['"\`]([a-zA-Z0-9._-]+)['"\`]/g
153
+ /(?:\bt\(|i18n\.t\(|i18nKey=)\s*['"\`]([a-zA-Z0-9._:-]+)['"\`]/g
110
154
  ];
111
155
 
112
156
  for (const file of files) {
113
157
  const content = await fs.readFile(file, 'utf-8');
114
158
  const lines = content.split('\n');
159
+ const namespaces = this.extractNamespace(content);
160
+ const defaultNamespace = namespaces.length > 0 ? namespaces[0] : null;
115
161
 
116
162
  patterns.forEach(regex => {
117
163
  let match;
118
- // Reset regex state for each file
119
164
  regex.lastIndex = 0;
120
165
  while ((match = regex.exec(content)) !== null) {
121
- const key = match[1];
166
+ let key = match[1];
167
+
168
+ // Normalize colon to dot for internal representation if it's a namespace separator
169
+ // and not just part of a key.
170
+ if (key.includes(':')) {
171
+ key = key.replace(':', '.');
172
+ } else if (defaultNamespace) {
173
+ // Apply default namespace if no colon was present
174
+ key = `${defaultNamespace}.${key}`;
175
+ }
122
176
 
123
- // VALIDATION: Skip dynamic keys
124
- // 1. Skip if it contains template literal placeholders ${...}
125
- // 2. Skip if it's just the placeholder prefix ${
126
- // 3. Skip if it looks like a variable (no dots, no spaces, starts with lowercase and followed by camelCase etc)
127
- // - actually, dots are good, but ${ is the killer.
128
177
  if (key.includes('${') || key.includes('`') || key.startsWith('$')) {
129
178
  continue;
130
179
  }
131
180
 
132
- // basic validation to avoid random strings and ensure it's not a translation file path
133
181
  if (key && !existingKeysSet.has(key) && !key.includes('/') && !key.includes('\\')) {
134
182
  if (includeContext) {
135
183
  if (!missingKeysData[key]) {
@@ -139,10 +187,9 @@ class Scanner {
139
187
  };
140
188
  }
141
189
 
142
- // Find line number
143
190
  const index = match.index;
144
191
  const lineNo = content.substring(0, index).split('\n').length;
145
- const contextRange = 2; // lines before and after
192
+ const contextRange = 2;
146
193
  const startLine = Math.max(0, lineNo - contextRange - 1);
147
194
  const endLine = Math.min(lines.length, lineNo + contextRange);
148
195
  const contextLines = lines.slice(startLine, endLine);
@@ -172,38 +219,49 @@ class Scanner {
172
219
  async findContextForKeys(keys) {
173
220
  const files = await this.getFiles();
174
221
  const contextData = {};
175
- const keysSet = new Set(keys);
176
222
 
177
223
  for (const file of files) {
178
224
  const content = await fs.readFile(file, 'utf-8');
179
225
  const lines = content.split('\n');
226
+ const namespaces = this.extractNamespace(content);
180
227
 
181
228
  keys.forEach(key => {
182
229
  const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
183
- const regex = new RegExp(`['"\`]${escapedKey}['"\`]`, 'g');
230
+ const regexes = [new RegExp(`['"\`]${escapedKey}['"\`]`, 'g')];
184
231
 
185
- let match;
186
- while ((match = regex.exec(content)) !== null) {
187
- if (!contextData[key]) {
188
- contextData[key] = {
189
- key,
190
- occurrences: []
191
- };
232
+ // Also check for relative key if it matches any of the namespaces
233
+ for (const namespace of namespaces) {
234
+ if (key.startsWith(namespace + '.')) {
235
+ const relativeKey = key.substring(namespace.length + 1);
236
+ const escapedRelativeKey = relativeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
237
+ regexes.push(new RegExp(`['"\`]${escapedRelativeKey}['"\`]`, 'g'));
192
238
  }
193
-
194
- const index = match.index;
195
- const lineNo = content.substring(0, index).split('\n').length;
196
- const contextRange = 2;
197
- const startLine = Math.max(0, lineNo - contextRange - 1);
198
- const endLine = Math.min(lines.length, lineNo + contextRange);
199
- const contextLines = lines.slice(startLine, endLine);
200
-
201
- contextData[key].occurrences.push({
202
- file: path.relative(this.targetDir, file),
203
- line: lineNo,
204
- context: contextLines.join('\n').trim()
205
- });
206
239
  }
240
+
241
+ regexes.forEach(regex => {
242
+ let match;
243
+ while ((match = regex.exec(content)) !== null) {
244
+ if (!contextData[key]) {
245
+ contextData[key] = {
246
+ key,
247
+ occurrences: []
248
+ };
249
+ }
250
+
251
+ const index = match.index;
252
+ const lineNo = content.substring(0, index).split('\n').length;
253
+ const contextRange = 2;
254
+ const startLine = Math.max(0, lineNo - contextRange - 1);
255
+ const endLine = Math.min(lines.length, lineNo + contextRange);
256
+ const contextLines = lines.slice(startLine, endLine);
257
+
258
+ contextData[key].occurrences.push({
259
+ file: path.relative(this.targetDir, file),
260
+ line: lineNo,
261
+ context: contextLines.join('\n').trim()
262
+ });
263
+ }
264
+ });
207
265
  });
208
266
  }
209
267
 
@@ -47,14 +47,49 @@ class Storage {
47
47
  */
48
48
  async readAll() {
49
49
  const localesDir = await this.getLocalesDir();
50
- const files = await fs.readdir(localesDir);
51
- const jsonFiles = files.filter(f => f.endsWith('.json'));
50
+ const entries = await fs.readdir(localesDir, { withFileTypes: true });
52
51
 
53
52
  const translations = {};
54
- for (const file of jsonFiles) {
55
- const lang = path.basename(file, '.json');
56
- const content = await fs.readJson(path.join(localesDir, file));
57
- translations[lang] = content;
53
+
54
+ const readDirectory = async (dir, lang, prefix = []) => {
55
+ const files = await fs.readdir(dir, { withFileTypes: true });
56
+ const data = {};
57
+
58
+ for (const file of files) {
59
+ const fullPath = path.join(dir, file.name);
60
+ if (file.isDirectory()) {
61
+ const nestedData = await readDirectory(fullPath, lang, [...prefix, file.name]);
62
+ if (Object.keys(nestedData).length > 0) {
63
+ data[file.name] = nestedData;
64
+ }
65
+ } else if (file.isFile() && file.name.endsWith('.json')) {
66
+ const ns = path.basename(file.name, '.json');
67
+ try {
68
+ data[ns] = await fs.readJson(fullPath);
69
+ } catch (err) {
70
+ throw new Error(`Failed to read translation file "${fullPath}": ${err.message}`);
71
+ }
72
+ }
73
+ }
74
+ return data;
75
+ };
76
+
77
+ for (const entry of entries) {
78
+ const fullPath = path.join(localesDir, entry.name);
79
+
80
+ if (entry.isFile() && entry.name.endsWith('.json')) {
81
+ const lang = path.basename(entry.name, '.json');
82
+ try {
83
+ const content = await fs.readJson(fullPath);
84
+ translations[lang] = { ...translations[lang], ...content };
85
+ } catch (err) {
86
+ throw new Error(`Failed to read translation file "${fullPath}": ${err.message}`);
87
+ }
88
+ } else if (entry.isDirectory()) {
89
+ const lang = entry.name;
90
+ const langData = await readDirectory(fullPath, lang);
91
+ translations[lang] = { ...translations[lang], ...langData };
92
+ }
58
93
  }
59
94
 
60
95
  return translations;
@@ -65,13 +100,42 @@ class Storage {
65
100
  */
66
101
  async write(lang, content, options = {}) {
67
102
  const localesDir = await this.getLocalesDir();
68
- const filePath = path.join(localesDir, `${lang}.json`);
103
+ const langDir = path.join(localesDir, lang);
69
104
 
70
105
  // Sort the content before writing if sorting is enabled (default: true)
71
106
  const shouldSort = options.sort !== false;
72
107
  const finalContent = shouldSort ? Utilities.sortObject(content) : content;
73
108
 
74
- await fs.writeJson(filePath, finalContent, { spaces: 2 });
109
+ const writeRecursive = async (dir, data) => {
110
+ await fs.ensureDir(dir);
111
+ for (const key in data) {
112
+ const value = data[key];
113
+ const fullPath = path.join(dir, `${key}.json`);
114
+
115
+ // If the value is an object and NOT an empty object, and we want to support nested dirs
116
+ // we need to decide if we write it as a file or a directory.
117
+ // In i18next, usually one file = one namespace. Nested keys inside the file are NOT separate files.
118
+ // HOWEVER, our readAll now supports nested directories.
119
+ // To be consistent, if we read a nested directory, we should probably write it back as one.
120
+
121
+ // Let's check if the directory already exists or if it's a new nested structure.
122
+ const potentialDir = path.join(dir, key);
123
+ if (typeof value === 'object' && value !== null && !Array.isArray(value) && await fs.pathExists(potentialDir) && (await fs.stat(potentialDir)).isDirectory()) {
124
+ await writeRecursive(potentialDir, value);
125
+ } else {
126
+ await fs.writeJson(fullPath, value, { spaces: 2 });
127
+ }
128
+ }
129
+ };
130
+
131
+ if (await fs.pathExists(langDir) && (await fs.stat(langDir)).isDirectory()) {
132
+ // Namespace mode
133
+ await writeRecursive(langDir, finalContent);
134
+ } else {
135
+ // Flat mode
136
+ const filePath = path.join(localesDir, `${lang}.json`);
137
+ await fs.writeJson(filePath, finalContent, { spaces: 2 });
138
+ }
75
139
  }
76
140
 
77
141
  /**
package/src/server.js CHANGED
@@ -3,6 +3,7 @@ const cors = require('cors');
3
3
  const path = require('path');
4
4
  const history = require('express-history-api-fallback');
5
5
  const TranslatorManager = require('./core/TranslatorManager');
6
+ const pkg = require('../package.json');
6
7
 
7
8
  /**
8
9
  * Starts the translation manager server.
@@ -194,7 +195,7 @@ function startServer(targetDir, port = 3000, config = {}) {
194
195
  });
195
196
 
196
197
  app.listen(port, () => {
197
- console.log(`\x1b[32m✔\x1b[0m Translation Manager UI is running at http://localhost:${port}`);
198
+ console.log(`\x1b[32m✔\x1b[0m Translation Manager v${pkg.version} is running at http://localhost:${port}`);
198
199
  });
199
200
  }
200
201
 
@@ -3,7 +3,7 @@
3
3
  This skill enables an AI agent to efficiently manage i18n translations using the `@tuhama/translation-manager` library.
4
4
 
5
5
  ## Context
6
- This project uses a custom translation manager that supports AI-powered context extraction. Translation keys are stored in JSON files (usually in `src/locales` or `locales`).
6
+ This project uses a custom translation manager that supports AI-powered context extraction. Translation keys are stored in JSON files (either flat files in `src/locales` or `locales`, or grouped into directories when using namespaces).
7
7
 
8
8
  ## Workflow
9
9
 
@@ -13,17 +13,19 @@ Before starting any work, always check the current status:
13
13
  - **Goal**: Identify missing translations, unused keys, and coverage percentage across all languages.
14
14
 
15
15
  ### 2. Identify Missing Keys from Code
16
- Find keys that are used in the source code (e.g., `t('key.name')`) but aren't defined in the JSON files:
16
+ Find keys that are used in the source code (e.g., `t('key.name')` or `t('namespace:key.name')`) but aren't defined in the JSON files:
17
17
  - **Action**: Use the `Scanner` or the Web UI to find "Missing from files" keys.
18
18
  - **AI Task**: If you find missing keys, suggest descriptive key names based on their usage.
19
19
 
20
20
  ### 3. Generate Context-Aware Translations
21
21
  When translating, leverage the extracted code context:
22
22
  - **Rule**: Use the surrounding code snippets to determine the correct nuance (e.g., is "Save" a button or a noun?).
23
+ - **Export**: The system allows exporting keys with context as JSON to facilitate bulk AI translation. Use this for mass updates.
23
24
  - **Style**: Maintain consistent tone across the app (default: professional and concise).
24
25
  - **Placeholders**: Always preserve `{name}`, `{{count}}`, or other interpolation markers.
25
26
 
26
- ### 4. Maintain Key Consistency
27
+ ### 4. Maintain Key Consistency & Namespaces
28
+ - **Namespaces**: The library supports namespaces (e.g., `namespace:key`). Ensure you respect the configured storage mode, storing them either as flattened keys or separated into directory-based namespace files.
27
29
  - **Naming**: Use dot-notation for nesting (e.g., `auth.login.button_label`).
28
30
  - **Sorting**: Keys should be kept in alphabetical order. Use the "Normalize" feature or `npx @tuhama/translation-manager normalize` (if available).
29
31
  - **Cleanup**: Periodically check for unused keys and remove them to keep bundle sizes small.
@@ -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:320px 1fr;display:grid;position:relative;overflow:hidden}@media (width<=1024px){.app-body{grid-template-columns:280px 1fr}}@media (width<=768px){.app-body{flex-direction:column;display:flex;overflow-y:auto}}.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}.logo-container{align-items:center;display:flex;position:relative}.logo-glow{background:var(--accent-color);filter:blur(15px);opacity:.2;z-index:-1;border-radius:50%;width:40px;height:40px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.title-stack{flex-direction:column;display:flex}.title-row{align-items:center;gap:10px;display:flex}.version-badge{color:var(--accent-color);text-transform:uppercase;letter-spacing:.5px;background:#7c3aed1a;border:1px solid #7c3aed33;border-radius:4px;padding:2px 6px;font-size:.65rem;font-weight:700}.header-stats{align-items:center;gap:12px;margin-top:2px;display:flex}.stat-item{align-items:center;gap:4px;display:flex}.stat-value{color:#fff;font-size:.75rem;font-weight:800}.stat-label{color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;font-size:.7rem}.stat-divider{background:var(--border-color);width:1px;height:10px}@media (width<=768px){.header-stats{display:none}}.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)}.mobile-menu-toggle{cursor:pointer;z-index:101;background:0 0;border:none;flex-direction:column;gap:4px;padding:8px;display:none}.mobile-menu-toggle span{background:var(--text-color);width:24px;height:2px;transition:var(--transition);border-radius:2px;display:block}@media (width<=768px){.mobile-menu-toggle{display:flex}.brand-section{gap:8px}.app-title{font-size:1.1rem}.header-logo{width:28px;height:28px}}.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;transition:transform .3s cubic-bezier(.4,0,.2,1);display:flex;overflow:hidden}@media (width<=768px){.sidebar{z-index:1000;background:#111827;width:300px;position:fixed;top:0;bottom:0;left:0;transform:translate(-100%);box-shadow:20px 0 50px #00000080}.sidebar.open{transform:translate(0)}.sidebar-overlay{-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:999;background:#0009;position:fixed;inset:0}}.sidebar-header-mobile{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:center;padding:20px;display:none}@media (width<=768px){.sidebar-header-mobile{display:flex}}.sidebar-title{text-transform:uppercase;letter-spacing:1px;color:var(--accent-color);font-size:.85rem;font-weight:700}.sidebar-close{color:var(--text-muted);cursor:pointer;background:0 0;border:none;font-size:1.5rem}.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-container:hover .dropdown-menu{opacity:1;pointer-events:auto;display:block;transform:translateY(0)}.dropdown-trigger{align-items:center;gap:8px;display:flex}.dropdown-menu{border:1px solid var(--border-color);z-index:1000;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);opacity:0;pointer-events:none;background:#111827;border-radius:12px;min-width:220px;padding:8px;transition:all .2s cubic-bezier(.4,0,.2,1);display:none;position:absolute;top:calc(100% + 8px);right:0;transform:translateY(10px);box-shadow:0 20px 40px #0009}.dropdown-menu:before{content:"";height:8px;position:absolute;top:-8px;left:0;right:0}.dropdown-item{width:100%;color:var(--text-color);cursor:pointer;transition:var(--transition);text-align:left;background:0 0;border:none;border-radius:8px;align-items:center;gap:12px;padding:10px 12px;font-size:.9rem;display:flex}.dropdown-item:hover{color:#fff;background:#7c3aed26}.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{background:radial-gradient(circle at 100% 0,#7c3aed0d,#0000 400px),radial-gradient(circle at 0 100%,#3b82f60d,#0000 400px);justify-content:center;align-items:flex-start;padding:24px;display:flex;overflow-y:auto}@media (width<=768px){.editor{background:0 0;padding:16px}}.editor-form{width:100%;max-width:1100px;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);background:#1f293766;border-radius:24px;flex-direction:column;height:fit-content;max-height:calc(100vh - 120px);display:flex;box-shadow:0 25px 50px -12px #00000080}@media (width<=768px){.editor-form{border-radius:16px;max-height:none}}.editor-main-form{flex-direction:column;flex:1;display:flex;overflow:hidden}.form-scroll-area{padding:32px 40px;overflow-y:auto}@media (width<=768px){.form-scroll-area{padding:24px 20px}}.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-sticky{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-top:1px solid var(--border-color);background:#111827cc;border-bottom-right-radius:24px;border-bottom-left-radius:24px;margin-top:auto;padding:20px 40px}@media (width<=768px){.form-actions-sticky{z-index:10;padding:16px 20px;position:sticky;bottom:0}}.form-actions-content{justify-content:flex-end;gap:16px;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-header h2,.modal-header h3{margin:0;font-size:1.25rem;font-weight:600}.settings-divider{border-bottom:1px solid var(--border-color);color:var(--primary-color);text-transform:uppercase;letter-spacing:.05em;margin:24px 0 16px;padding-bottom:8px;font-size:.9rem;font-weight:600}.close-btn{color:var(--text-muted);cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;width:36px;height:36px;padding:0;font-size:1.8rem;line-height:1;display:flex}.close-btn:hover{color:#fff;background:#ffffff0d}.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{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:center;padding:24px 40px;display:flex}@media (width<=768px){.editor-header-row{flex-direction:column;align-items:flex-start;gap:16px;padding:20px}}.editor-title-container{flex-direction:column;gap:4px;display:flex}.editor-subtitle{text-transform:uppercase;letter-spacing:.1em;color:var(--accent-color);font-size:.75rem;font-weight:700}.key-breadcrumbs{flex-wrap:wrap;align-items:center;gap:4px;display:flex}.breadcrumb-part{color:#fff}.breadcrumb-separator{color:var(--text-muted);opacity:.5}.languages-grid{grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:24px;display:grid}@media (width<=1200px){.languages-grid{grid-template-columns:1fr}}.full-width-field{grid-column:1/-1}.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}.warning-badge{color:#fbbf24;cursor:pointer;transition:var(--transition);background:#f59e0b1a;border:1px solid #f59e0b33;border-radius:8px;align-items:center;gap:8px;padding:8px 14px;font-size:.8rem;font-weight:600;display:flex}.warning-badge:hover{background:#f59e0b33;transform:translateY(-1px)}.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}.settings-select{border:1px solid var(--border-color);color:#fff;appearance:none;cursor:pointer;width:100%;transition:var(--transition);background:#0000004d url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e") right 14px center/16px no-repeat;border-radius:10px;padding:10px 14px;font-size:1rem}.settings-select:hover{border-color:var(--accent-color);background-color:#ffffff0d}.settings-select:focus{border-color:var(--accent-color);outline:none;box-shadow:0 0 0 2px #7c3aed1a}.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}.tab-container{margin-top:24px}.tab-buttons{border-bottom:1px solid var(--border-color);gap:12px;margin-bottom:24px;padding-bottom:8px;display:flex}.tab-button{color:var(--text-muted);cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:8px;padding:8px 16px;font-size:.95rem;font-weight:600;position:relative}.tab-button:hover{color:var(--text-color);background:#ffffff0d}.tab-button.active{color:var(--accent-color);background:#7c3aed1a}.tab-button.active:after{content:"";background:var(--accent-color);border-radius:2px 2px 0 0;height:2px;position:absolute;bottom:-9px;left:0;right:0}.export-tab,.import-tab{padding:8px 0}.export-info{border-left:4px solid var(--accent-color);background:#7c3aed0d;border-radius:4px 12px 12px 4px;margin-bottom:24px;padding:12px 16px}.export-info p{color:var(--text-color);margin:0;font-size:.9rem}.export-preview h3,.import-options h3{color:var(--text-color);margin-bottom:16px;font-size:1.1rem;font-weight:700}.preview-stats{grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:20px;display:grid}.preview-stats p{border:1px solid var(--border-color);background:#ffffff08;border-radius:12px;margin:0;padding:12px 16px;font-size:.9rem}.preview-sample h4{color:var(--text-muted);margin-bottom:12px;font-size:.9rem}.import-options{border-top:1px solid var(--border-color);margin-top:24px;padding-top:24px}.import-options label{cursor:pointer;color:var(--text-color);transition:var(--transition);align-items:center;gap:10px;margin-bottom:12px;font-size:.9rem;display:flex}.import-options label:hover{color:var(--accent-color)}.import-options input[type=checkbox]{accent-color:var(--accent-color);cursor:pointer;width:18px;height:18px}.export-import-modal .modal-content{max-width:600px}