@idealyst/translate 1.2.3
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 +773 -0
- package/package.json +77 -0
- package/src/babel/__tests__/extractor.test.ts +224 -0
- package/src/babel/__tests__/plugin.test.ts +289 -0
- package/src/babel/__tests__/reporter.test.ts +314 -0
- package/src/babel/extractor.ts +179 -0
- package/src/babel/index.ts +21 -0
- package/src/babel/plugin.js +545 -0
- package/src/babel/reporter.ts +287 -0
- package/src/babel/types.ts +214 -0
- package/src/components/Trans.tsx +72 -0
- package/src/components/index.ts +2 -0
- package/src/components/types.ts +64 -0
- package/src/config/index.ts +2 -0
- package/src/config/types.ts +10 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/types.ts +113 -0
- package/src/hooks/useLanguage.ts +73 -0
- package/src/hooks/useTranslation.ts +52 -0
- package/src/index.native.ts +2 -0
- package/src/index.ts +22 -0
- package/src/index.web.ts +24 -0
- package/src/provider/TranslateProvider.tsx +132 -0
- package/src/provider/index.ts +6 -0
- package/src/provider/types.ts +119 -0
- package/src/utils/__tests__/namespace.test.ts +211 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/namespace.ts +97 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @idealyst/translate Babel plugin
|
|
3
|
+
*
|
|
4
|
+
* Extracts translation keys from source code and generates a report
|
|
5
|
+
* of missing and unused translations.
|
|
6
|
+
*
|
|
7
|
+
* Usage in babel.config.js:
|
|
8
|
+
* ```js
|
|
9
|
+
* module.exports = {
|
|
10
|
+
* plugins: [
|
|
11
|
+
* ['@idealyst/translate/plugin', {
|
|
12
|
+
* translationFiles: ['./locales/**\/*.json'],
|
|
13
|
+
* reportPath: '.idealyst/translations-report.json',
|
|
14
|
+
* defaultNamespace: 'common',
|
|
15
|
+
* emitWarnings: true,
|
|
16
|
+
* failOnMissing: false,
|
|
17
|
+
* }],
|
|
18
|
+
* ],
|
|
19
|
+
* };
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
// Try to use glob, fall back to simple matching if not available
|
|
27
|
+
let glob;
|
|
28
|
+
try {
|
|
29
|
+
glob = require('glob');
|
|
30
|
+
} catch {
|
|
31
|
+
glob = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Global registry for collecting keys across all files
|
|
36
|
+
* Persists across file visits during the build
|
|
37
|
+
*/
|
|
38
|
+
const globalRegistry = {
|
|
39
|
+
keys: new Map(),
|
|
40
|
+
options: null,
|
|
41
|
+
initialized: false,
|
|
42
|
+
|
|
43
|
+
addKey(key) {
|
|
44
|
+
const existing = this.keys.get(key.key) || [];
|
|
45
|
+
existing.push(key);
|
|
46
|
+
this.keys.set(key.key, existing);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
getAllKeys() {
|
|
50
|
+
return Array.from(this.keys.values()).flat();
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
getStaticKeys() {
|
|
54
|
+
return this.getAllKeys().filter((k) => !k.isDynamic);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
getDynamicKeys() {
|
|
58
|
+
return this.getAllKeys().filter((k) => k.isDynamic);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
getUniqueKeys() {
|
|
62
|
+
return Array.from(new Set(this.getStaticKeys().map((k) => k.key)));
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
getKeyUsages(key) {
|
|
66
|
+
return this.keys.get(key) || [];
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
hasKey(key) {
|
|
70
|
+
return this.keys.has(key);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
getKeyCount() {
|
|
74
|
+
return this.keys.size;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
clear() {
|
|
78
|
+
this.keys.clear();
|
|
79
|
+
this.options = null;
|
|
80
|
+
this.initialized = false;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse a key into namespace and local key
|
|
86
|
+
*/
|
|
87
|
+
function parseKey(fullKey, defaultNamespace = 'translation') {
|
|
88
|
+
// Handle namespace:key format
|
|
89
|
+
if (fullKey.includes(':')) {
|
|
90
|
+
const [namespace, ...rest] = fullKey.split(':');
|
|
91
|
+
return { namespace, localKey: rest.join(':') };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Handle namespace.key format (first segment is namespace)
|
|
95
|
+
const segments = fullKey.split('.');
|
|
96
|
+
if (segments.length > 1) {
|
|
97
|
+
return { namespace: segments[0], localKey: segments.slice(1).join('.') };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { namespace: defaultNamespace, localKey: fullKey };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if an object has a nested key
|
|
105
|
+
*/
|
|
106
|
+
function hasNestedKey(obj, keyPath) {
|
|
107
|
+
const parts = keyPath.split('.');
|
|
108
|
+
let current = obj;
|
|
109
|
+
|
|
110
|
+
for (const part of parts) {
|
|
111
|
+
if (current === undefined || current === null) return false;
|
|
112
|
+
if (typeof current !== 'object') return false;
|
|
113
|
+
current = current[part];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return current !== undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Flatten an object's keys
|
|
121
|
+
*/
|
|
122
|
+
function flattenKeys(obj, prefix = '') {
|
|
123
|
+
const keys = [];
|
|
124
|
+
|
|
125
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
126
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
127
|
+
|
|
128
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
129
|
+
keys.push(...flattenKeys(value, fullKey));
|
|
130
|
+
} else {
|
|
131
|
+
keys.push(fullKey);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return keys;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Extract language code from file path
|
|
140
|
+
*/
|
|
141
|
+
function extractLanguageFromPath(filePath) {
|
|
142
|
+
const parts = filePath.split(path.sep);
|
|
143
|
+
const filename = path.basename(filePath, '.json');
|
|
144
|
+
|
|
145
|
+
// Check if filename is a language code (e.g., en.json, es-MX.json)
|
|
146
|
+
if (filename.match(/^[a-z]{2}(-[A-Z]{2})?$/)) {
|
|
147
|
+
return filename;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check parent directory (e.g., locales/en/common.json)
|
|
151
|
+
const parentDir = parts[parts.length - 2];
|
|
152
|
+
if (parentDir && parentDir.match(/^[a-z]{2}(-[A-Z]{2})?$/)) {
|
|
153
|
+
return parentDir;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return filename;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Extract namespace from file path
|
|
161
|
+
*/
|
|
162
|
+
function extractNamespaceFromPath(filePath) {
|
|
163
|
+
const filename = path.basename(filePath, '.json');
|
|
164
|
+
|
|
165
|
+
// If filename is a language code, use 'translation' as default namespace
|
|
166
|
+
if (filename.match(/^[a-z]{2}(-[A-Z]{2})?$/)) {
|
|
167
|
+
return 'translation';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return filename;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Load translation files
|
|
175
|
+
*/
|
|
176
|
+
function loadTranslations(patterns, verbose = false) {
|
|
177
|
+
const result = {};
|
|
178
|
+
|
|
179
|
+
for (const pattern of patterns) {
|
|
180
|
+
let files = [];
|
|
181
|
+
|
|
182
|
+
if (glob && glob.sync) {
|
|
183
|
+
try {
|
|
184
|
+
files = glob.sync(pattern);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (verbose) {
|
|
187
|
+
console.warn(`[@idealyst/translate] Failed to glob pattern: ${pattern}`, err);
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
// Simple fallback: treat pattern as direct file path
|
|
193
|
+
if (fs.existsSync(pattern)) {
|
|
194
|
+
files = [pattern];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const file of files) {
|
|
199
|
+
try {
|
|
200
|
+
const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
201
|
+
const lang = extractLanguageFromPath(file);
|
|
202
|
+
const namespace = extractNamespaceFromPath(file);
|
|
203
|
+
|
|
204
|
+
if (!result[lang]) result[lang] = {};
|
|
205
|
+
result[lang][namespace] = content;
|
|
206
|
+
|
|
207
|
+
if (verbose) {
|
|
208
|
+
console.log(`[@idealyst/translate] Loaded: ${file} -> ${lang}/${namespace}`);
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (verbose) {
|
|
212
|
+
console.warn(`[@idealyst/translate] Failed to load: ${file}`, err);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Generate the translations report
|
|
223
|
+
*/
|
|
224
|
+
function generateReport(keys, options) {
|
|
225
|
+
const {
|
|
226
|
+
translationFiles = [],
|
|
227
|
+
languages: configLanguages,
|
|
228
|
+
defaultNamespace = 'translation',
|
|
229
|
+
verbose = false,
|
|
230
|
+
} = options;
|
|
231
|
+
|
|
232
|
+
const translations = loadTranslations(translationFiles, verbose);
|
|
233
|
+
const languages = configLanguages || Object.keys(translations);
|
|
234
|
+
|
|
235
|
+
const staticKeys = keys.filter((k) => !k.isDynamic);
|
|
236
|
+
const dynamicKeys = keys.filter((k) => k.isDynamic);
|
|
237
|
+
const uniqueKeys = [...new Set(staticKeys.map((k) => k.key))];
|
|
238
|
+
|
|
239
|
+
// Find missing translations per language
|
|
240
|
+
const missing = {};
|
|
241
|
+
for (const lang of languages) {
|
|
242
|
+
missing[lang] = [];
|
|
243
|
+
const langTranslations = translations[lang] || {};
|
|
244
|
+
|
|
245
|
+
for (const key of uniqueKeys) {
|
|
246
|
+
const { namespace, localKey } = parseKey(key, defaultNamespace);
|
|
247
|
+
const nsTranslations = langTranslations[namespace] || {};
|
|
248
|
+
|
|
249
|
+
if (!hasNestedKey(nsTranslations, localKey)) {
|
|
250
|
+
const usages = staticKeys.filter((k) => k.key === key);
|
|
251
|
+
missing[lang].push({
|
|
252
|
+
key,
|
|
253
|
+
namespace,
|
|
254
|
+
usedIn: usages.map((u) => ({
|
|
255
|
+
file: u.file,
|
|
256
|
+
line: u.line,
|
|
257
|
+
column: u.column,
|
|
258
|
+
})),
|
|
259
|
+
defaultValue: usages[0]?.defaultValue,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Find unused translations per language
|
|
266
|
+
const unused = {};
|
|
267
|
+
for (const lang of languages) {
|
|
268
|
+
unused[lang] = [];
|
|
269
|
+
const langTranslations = translations[lang] || {};
|
|
270
|
+
|
|
271
|
+
for (const namespace of Object.keys(langTranslations)) {
|
|
272
|
+
const keys = flattenKeys(langTranslations[namespace], namespace);
|
|
273
|
+
for (const translationKey of keys) {
|
|
274
|
+
if (!uniqueKeys.includes(translationKey)) {
|
|
275
|
+
unused[lang].push(translationKey);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Calculate coverage
|
|
282
|
+
const coveragePercent = {};
|
|
283
|
+
for (const lang of languages) {
|
|
284
|
+
const totalKeys = uniqueKeys.length;
|
|
285
|
+
const missingCount = missing[lang].length;
|
|
286
|
+
coveragePercent[lang] =
|
|
287
|
+
totalKeys > 0 ? Math.round(((totalKeys - missingCount) / totalKeys) * 100) : 100;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
timestamp: new Date().toISOString(),
|
|
292
|
+
totalKeys: uniqueKeys.length,
|
|
293
|
+
dynamicKeys,
|
|
294
|
+
extractedKeys: staticKeys,
|
|
295
|
+
languages,
|
|
296
|
+
missing,
|
|
297
|
+
unused,
|
|
298
|
+
summary: {
|
|
299
|
+
totalMissing: Object.values(missing).flat().length,
|
|
300
|
+
totalUnused: Object.values(unused).flat().length,
|
|
301
|
+
coveragePercent,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Write the report to disk
|
|
308
|
+
*/
|
|
309
|
+
function writeReport(report, outputPath) {
|
|
310
|
+
const dir = path.dirname(outputPath);
|
|
311
|
+
if (!fs.existsSync(dir)) {
|
|
312
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
313
|
+
}
|
|
314
|
+
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The Babel plugin
|
|
319
|
+
*/
|
|
320
|
+
module.exports = function translatePlugin({ types: t }) {
|
|
321
|
+
return {
|
|
322
|
+
name: 'idealyst-translate',
|
|
323
|
+
|
|
324
|
+
pre(state) {
|
|
325
|
+
// Initialize extracted keys for this file
|
|
326
|
+
this.extractedKeys = [];
|
|
327
|
+
|
|
328
|
+
// Store options on first file
|
|
329
|
+
if (!globalRegistry.options && this.opts) {
|
|
330
|
+
globalRegistry.options = this.opts;
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
visitor: {
|
|
335
|
+
/**
|
|
336
|
+
* Extract keys from t() and i18n.t() calls
|
|
337
|
+
*/
|
|
338
|
+
CallExpression(path, state) {
|
|
339
|
+
const { node } = path;
|
|
340
|
+
const filename = state.file.opts.filename || 'unknown';
|
|
341
|
+
const options = state.opts || {};
|
|
342
|
+
const defaultNamespace = options.defaultNamespace || 'translation';
|
|
343
|
+
|
|
344
|
+
let isTCall = false;
|
|
345
|
+
|
|
346
|
+
// Check for t('key') calls
|
|
347
|
+
if (t.isIdentifier(node.callee, { name: 't' })) {
|
|
348
|
+
isTCall = true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Check for i18n.t('key') calls
|
|
352
|
+
if (
|
|
353
|
+
t.isMemberExpression(node.callee) &&
|
|
354
|
+
t.isIdentifier(node.callee.property, { name: 't' })
|
|
355
|
+
) {
|
|
356
|
+
isTCall = true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!isTCall) return;
|
|
360
|
+
|
|
361
|
+
const firstArg = node.arguments[0];
|
|
362
|
+
if (!firstArg) return;
|
|
363
|
+
|
|
364
|
+
// Static string literal
|
|
365
|
+
if (t.isStringLiteral(firstArg)) {
|
|
366
|
+
const { namespace, localKey } = parseKey(firstArg.value, defaultNamespace);
|
|
367
|
+
|
|
368
|
+
// Try to extract defaultValue from options object
|
|
369
|
+
let defaultValue;
|
|
370
|
+
const secondArg = node.arguments[1];
|
|
371
|
+
if (t.isObjectExpression(secondArg)) {
|
|
372
|
+
const defaultProp = secondArg.properties.find(
|
|
373
|
+
(p) =>
|
|
374
|
+
t.isObjectProperty(p) &&
|
|
375
|
+
t.isIdentifier(p.key, { name: 'defaultValue' }) &&
|
|
376
|
+
t.isStringLiteral(p.value)
|
|
377
|
+
);
|
|
378
|
+
if (defaultProp && t.isObjectProperty(defaultProp) && t.isStringLiteral(defaultProp.value)) {
|
|
379
|
+
defaultValue = defaultProp.value.value;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.extractedKeys.push({
|
|
384
|
+
key: firstArg.value,
|
|
385
|
+
namespace,
|
|
386
|
+
localKey,
|
|
387
|
+
file: filename,
|
|
388
|
+
line: node.loc?.start.line ?? 0,
|
|
389
|
+
column: node.loc?.start.column ?? 0,
|
|
390
|
+
defaultValue,
|
|
391
|
+
isDynamic: false,
|
|
392
|
+
});
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Template literal with no expressions (treated as static)
|
|
397
|
+
if (t.isTemplateLiteral(firstArg) && firstArg.expressions.length === 0) {
|
|
398
|
+
const value = firstArg.quasis[0].value.raw;
|
|
399
|
+
const { namespace, localKey } = parseKey(value, defaultNamespace);
|
|
400
|
+
|
|
401
|
+
this.extractedKeys.push({
|
|
402
|
+
key: value,
|
|
403
|
+
namespace,
|
|
404
|
+
localKey,
|
|
405
|
+
file: filename,
|
|
406
|
+
line: node.loc?.start.line ?? 0,
|
|
407
|
+
column: node.loc?.start.column ?? 0,
|
|
408
|
+
isDynamic: false,
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Dynamic key - record but mark as such
|
|
414
|
+
this.extractedKeys.push({
|
|
415
|
+
key: '<dynamic>',
|
|
416
|
+
namespace: defaultNamespace,
|
|
417
|
+
localKey: '<dynamic>',
|
|
418
|
+
file: filename,
|
|
419
|
+
line: node.loc?.start.line ?? 0,
|
|
420
|
+
column: node.loc?.start.column ?? 0,
|
|
421
|
+
isDynamic: true,
|
|
422
|
+
});
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Extract keys from <Trans i18nKey="..." /> components
|
|
427
|
+
*/
|
|
428
|
+
JSXOpeningElement(path, state) {
|
|
429
|
+
const { node } = path;
|
|
430
|
+
const filename = state.file.opts.filename || 'unknown';
|
|
431
|
+
const options = state.opts || {};
|
|
432
|
+
const defaultNamespace = options.defaultNamespace || 'translation';
|
|
433
|
+
|
|
434
|
+
// Check for Trans component
|
|
435
|
+
if (!t.isJSXIdentifier(node.name, { name: 'Trans' })) return;
|
|
436
|
+
|
|
437
|
+
// Find i18nKey attribute
|
|
438
|
+
const i18nKeyAttr = node.attributes.find(
|
|
439
|
+
(attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'i18nKey' })
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
if (!i18nKeyAttr || !t.isJSXAttribute(i18nKeyAttr)) return;
|
|
443
|
+
|
|
444
|
+
const value = i18nKeyAttr.value;
|
|
445
|
+
|
|
446
|
+
// String literal value
|
|
447
|
+
if (t.isStringLiteral(value)) {
|
|
448
|
+
const { namespace, localKey } = parseKey(value.value, defaultNamespace);
|
|
449
|
+
|
|
450
|
+
this.extractedKeys.push({
|
|
451
|
+
key: value.value,
|
|
452
|
+
namespace,
|
|
453
|
+
localKey,
|
|
454
|
+
file: filename,
|
|
455
|
+
line: node.loc?.start.line ?? 0,
|
|
456
|
+
column: node.loc?.start.column ?? 0,
|
|
457
|
+
isDynamic: false,
|
|
458
|
+
});
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// JSX expression with string literal
|
|
463
|
+
if (
|
|
464
|
+
t.isJSXExpressionContainer(value) &&
|
|
465
|
+
t.isStringLiteral(value.expression)
|
|
466
|
+
) {
|
|
467
|
+
const { namespace, localKey } = parseKey(value.expression.value, defaultNamespace);
|
|
468
|
+
|
|
469
|
+
this.extractedKeys.push({
|
|
470
|
+
key: value.expression.value,
|
|
471
|
+
namespace,
|
|
472
|
+
localKey,
|
|
473
|
+
file: filename,
|
|
474
|
+
line: node.loc?.start.line ?? 0,
|
|
475
|
+
column: node.loc?.start.column ?? 0,
|
|
476
|
+
isDynamic: false,
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Dynamic key
|
|
482
|
+
this.extractedKeys.push({
|
|
483
|
+
key: '<dynamic>',
|
|
484
|
+
namespace: defaultNamespace,
|
|
485
|
+
localKey: '<dynamic>',
|
|
486
|
+
file: filename,
|
|
487
|
+
line: node.loc?.start.line ?? 0,
|
|
488
|
+
column: node.loc?.start.column ?? 0,
|
|
489
|
+
isDynamic: true,
|
|
490
|
+
});
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
post(state) {
|
|
495
|
+
// Add all extracted keys to the global registry
|
|
496
|
+
for (const key of this.extractedKeys) {
|
|
497
|
+
globalRegistry.addKey(key);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const options = state.opts || globalRegistry.options || {};
|
|
501
|
+
|
|
502
|
+
// Only generate report after all files have been processed
|
|
503
|
+
// We use a simple heuristic: write on every file but overwrite
|
|
504
|
+
if (options.translationFiles && options.translationFiles.length > 0) {
|
|
505
|
+
const reportPath = options.reportPath || '.idealyst/translations-report.json';
|
|
506
|
+
const allKeys = globalRegistry.getAllKeys();
|
|
507
|
+
|
|
508
|
+
// Generate and write report
|
|
509
|
+
const report = generateReport(allKeys, options);
|
|
510
|
+
writeReport(report, reportPath);
|
|
511
|
+
|
|
512
|
+
// Emit warnings if configured
|
|
513
|
+
if (options.emitWarnings !== false) {
|
|
514
|
+
for (const lang of report.languages) {
|
|
515
|
+
const missingCount = report.missing[lang]?.length || 0;
|
|
516
|
+
if (missingCount > 0) {
|
|
517
|
+
console.warn(
|
|
518
|
+
`[@idealyst/translate] ${lang}: ${missingCount} missing translation(s)`
|
|
519
|
+
);
|
|
520
|
+
if (options.verbose) {
|
|
521
|
+
for (const m of report.missing[lang]) {
|
|
522
|
+
console.warn(` - ${m.key} (used in ${m.usedIn.length} file(s))`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Fail build if configured
|
|
530
|
+
if (options.failOnMissing && report.summary.totalMissing > 0) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`[@idealyst/translate] Build failed: ${report.summary.totalMissing} missing translation(s). ` +
|
|
533
|
+
`See ${reportPath} for details.`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// Export utilities for programmatic use
|
|
542
|
+
module.exports.globalRegistry = globalRegistry;
|
|
543
|
+
module.exports.generateReport = generateReport;
|
|
544
|
+
module.exports.writeReport = writeReport;
|
|
545
|
+
module.exports.parseKey = parseKey;
|