@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.
@@ -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;