@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,314 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { generateReport, writeReport, loadTranslations } from '../reporter';
4
+ import type { ExtractedKey, TranslatePluginOptions } from '../types';
5
+
6
+ describe('loadTranslations', () => {
7
+ const tempDir = path.join(__dirname, '.temp-loader-test');
8
+
9
+ beforeAll(() => {
10
+ fs.mkdirSync(path.join(tempDir, 'en'), { recursive: true });
11
+ fs.mkdirSync(path.join(tempDir, 'es'), { recursive: true });
12
+
13
+ fs.writeFileSync(
14
+ path.join(tempDir, 'en', 'common.json'),
15
+ JSON.stringify({ greeting: 'Hello' })
16
+ );
17
+
18
+ fs.writeFileSync(
19
+ path.join(tempDir, 'es', 'common.json'),
20
+ JSON.stringify({ greeting: 'Hola' })
21
+ );
22
+ });
23
+
24
+ afterAll(() => {
25
+ fs.rmSync(tempDir, { recursive: true, force: true });
26
+ });
27
+
28
+ it('loads translations from directory structure', () => {
29
+ const translations = loadTranslations(
30
+ [path.join(tempDir, '**/*.json')],
31
+ false
32
+ );
33
+
34
+ expect(translations).toHaveProperty('en');
35
+ expect(translations).toHaveProperty('es');
36
+ expect(translations.en.common).toEqual({ greeting: 'Hello' });
37
+ expect(translations.es.common).toEqual({ greeting: 'Hola' });
38
+ });
39
+
40
+ it('handles non-existent paths gracefully', () => {
41
+ const translations = loadTranslations(['/nonexistent/path/*.json'], false);
42
+ expect(Object.keys(translations)).toHaveLength(0);
43
+ });
44
+ });
45
+
46
+ describe('generateReport', () => {
47
+ const tempDir = path.join(__dirname, '.temp-report-test');
48
+
49
+ beforeAll(() => {
50
+ fs.mkdirSync(path.join(tempDir, 'en'), { recursive: true });
51
+ fs.mkdirSync(path.join(tempDir, 'es'), { recursive: true });
52
+ fs.mkdirSync(path.join(tempDir, 'fr'), { recursive: true });
53
+
54
+ fs.writeFileSync(
55
+ path.join(tempDir, 'en', 'common.json'),
56
+ JSON.stringify({
57
+ buttons: {
58
+ submit: 'Submit',
59
+ cancel: 'Cancel',
60
+ },
61
+ unused: 'Not used anywhere',
62
+ })
63
+ );
64
+
65
+ fs.writeFileSync(
66
+ path.join(tempDir, 'es', 'common.json'),
67
+ JSON.stringify({
68
+ buttons: {
69
+ submit: 'Enviar',
70
+ // cancel is missing
71
+ },
72
+ })
73
+ );
74
+
75
+ fs.writeFileSync(
76
+ path.join(tempDir, 'fr', 'common.json'),
77
+ JSON.stringify({
78
+ buttons: {
79
+ submit: 'Soumettre',
80
+ cancel: 'Annuler',
81
+ },
82
+ })
83
+ );
84
+ });
85
+
86
+ afterAll(() => {
87
+ fs.rmSync(tempDir, { recursive: true, force: true });
88
+ });
89
+
90
+ const createKeys = (): ExtractedKey[] => [
91
+ {
92
+ key: 'common.buttons.submit',
93
+ namespace: 'common',
94
+ localKey: 'buttons.submit',
95
+ file: 'Form.tsx',
96
+ line: 10,
97
+ column: 5,
98
+ isDynamic: false,
99
+ },
100
+ {
101
+ key: 'common.buttons.cancel',
102
+ namespace: 'common',
103
+ localKey: 'buttons.cancel',
104
+ file: 'Form.tsx',
105
+ line: 11,
106
+ column: 5,
107
+ isDynamic: false,
108
+ },
109
+ {
110
+ key: '<dynamic>',
111
+ namespace: 'common',
112
+ localKey: '<dynamic>',
113
+ file: 'Dynamic.tsx',
114
+ line: 5,
115
+ column: 0,
116
+ isDynamic: true,
117
+ },
118
+ ];
119
+
120
+ const createOptions = (): TranslatePluginOptions => ({
121
+ translationFiles: [path.join(tempDir, '**/*.json')],
122
+ defaultNamespace: 'common',
123
+ });
124
+
125
+ it('generates report with correct structure', () => {
126
+ const report = generateReport(createKeys(), createOptions());
127
+
128
+ expect(report).toHaveProperty('timestamp');
129
+ expect(report).toHaveProperty('totalKeys');
130
+ expect(report).toHaveProperty('extractedKeys');
131
+ expect(report).toHaveProperty('dynamicKeys');
132
+ expect(report).toHaveProperty('languages');
133
+ expect(report).toHaveProperty('missing');
134
+ expect(report).toHaveProperty('unused');
135
+ expect(report).toHaveProperty('summary');
136
+ });
137
+
138
+ it('counts only static keys in totalKeys', () => {
139
+ const report = generateReport(createKeys(), createOptions());
140
+
141
+ expect(report.totalKeys).toBe(2); // Excludes dynamic key
142
+ });
143
+
144
+ it('separates static and dynamic keys', () => {
145
+ const report = generateReport(createKeys(), createOptions());
146
+
147
+ expect(report.extractedKeys).toHaveLength(2);
148
+ expect(report.dynamicKeys).toHaveLength(1);
149
+ });
150
+
151
+ it('detects missing translations', () => {
152
+ const report = generateReport(createKeys(), createOptions());
153
+
154
+ // Spanish is missing 'cancel'
155
+ expect(report.missing.es).toHaveLength(1);
156
+ expect(report.missing.es[0].key).toBe('common.buttons.cancel');
157
+
158
+ // English and French have all keys
159
+ expect(report.missing.en).toHaveLength(0);
160
+ expect(report.missing.fr).toHaveLength(0);
161
+ });
162
+
163
+ it('includes usage locations for missing keys', () => {
164
+ const report = generateReport(createKeys(), createOptions());
165
+
166
+ const missingCancel = report.missing.es[0];
167
+ expect(missingCancel.usedIn).toHaveLength(1);
168
+ expect(missingCancel.usedIn[0].file).toBe('Form.tsx');
169
+ expect(missingCancel.usedIn[0].line).toBe(11);
170
+ });
171
+
172
+ it('detects unused translations', () => {
173
+ const report = generateReport(createKeys(), createOptions());
174
+
175
+ // 'unused' key exists in en/common.json but not in code
176
+ expect(report.unused.en).toContain('common.unused');
177
+ });
178
+
179
+ it('calculates coverage percentages correctly', () => {
180
+ const report = generateReport(createKeys(), createOptions());
181
+
182
+ // English: 2/2 = 100%
183
+ expect(report.summary.coveragePercent.en).toBe(100);
184
+
185
+ // Spanish: 1/2 = 50%
186
+ expect(report.summary.coveragePercent.es).toBe(50);
187
+
188
+ // French: 2/2 = 100%
189
+ expect(report.summary.coveragePercent.fr).toBe(100);
190
+ });
191
+
192
+ it('calculates summary totals', () => {
193
+ const report = generateReport(createKeys(), createOptions());
194
+
195
+ expect(report.summary.totalMissing).toBe(1); // Only Spanish missing 1
196
+ expect(report.summary.totalUnused).toBeGreaterThan(0);
197
+ });
198
+
199
+ it('includes timestamp in ISO format', () => {
200
+ const report = generateReport(createKeys(), createOptions());
201
+
202
+ expect(() => new Date(report.timestamp)).not.toThrow();
203
+ expect(report.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
204
+ });
205
+
206
+ it('handles empty keys array', () => {
207
+ const report = generateReport([], createOptions());
208
+
209
+ expect(report.totalKeys).toBe(0);
210
+ expect(report.extractedKeys).toHaveLength(0);
211
+ expect(report.summary.coveragePercent.en).toBe(100); // No keys = 100% coverage
212
+ });
213
+
214
+ it('handles keys with defaultValue', () => {
215
+ const keysWithDefault: ExtractedKey[] = [
216
+ {
217
+ key: 'common.new.feature',
218
+ namespace: 'common',
219
+ localKey: 'new.feature',
220
+ file: 'New.tsx',
221
+ line: 1,
222
+ column: 0,
223
+ isDynamic: false,
224
+ defaultValue: 'New Feature',
225
+ },
226
+ ];
227
+
228
+ const report = generateReport(keysWithDefault, createOptions());
229
+
230
+ const missingKey = report.missing.en.find(
231
+ (m) => m.key === 'common.new.feature'
232
+ );
233
+ expect(missingKey?.defaultValue).toBe('New Feature');
234
+ });
235
+ });
236
+
237
+ describe('writeReport', () => {
238
+ const tempDir = path.join(__dirname, '.temp-write-test');
239
+
240
+ afterEach(() => {
241
+ fs.rmSync(tempDir, { recursive: true, force: true });
242
+ });
243
+
244
+ it('writes report to file', () => {
245
+ const report = {
246
+ timestamp: new Date().toISOString(),
247
+ totalKeys: 0,
248
+ extractedKeys: [],
249
+ dynamicKeys: [],
250
+ languages: ['en'],
251
+ missing: { en: [] },
252
+ unused: { en: [] },
253
+ summary: {
254
+ totalMissing: 0,
255
+ totalUnused: 0,
256
+ coveragePercent: { en: 100 },
257
+ },
258
+ };
259
+
260
+ const outputPath = path.join(tempDir, 'report.json');
261
+ writeReport(report, outputPath);
262
+
263
+ expect(fs.existsSync(outputPath)).toBe(true);
264
+
265
+ const written = JSON.parse(fs.readFileSync(outputPath, 'utf-8'));
266
+ expect(written).toEqual(report);
267
+ });
268
+
269
+ it('creates nested directories if needed', () => {
270
+ const report = {
271
+ timestamp: new Date().toISOString(),
272
+ totalKeys: 0,
273
+ extractedKeys: [],
274
+ dynamicKeys: [],
275
+ languages: [],
276
+ missing: {},
277
+ unused: {},
278
+ summary: {
279
+ totalMissing: 0,
280
+ totalUnused: 0,
281
+ coveragePercent: {},
282
+ },
283
+ };
284
+
285
+ const outputPath = path.join(tempDir, 'deep', 'nested', 'report.json');
286
+ writeReport(report, outputPath);
287
+
288
+ expect(fs.existsSync(outputPath)).toBe(true);
289
+ });
290
+
291
+ it('formats JSON with indentation', () => {
292
+ const report = {
293
+ timestamp: new Date().toISOString(),
294
+ totalKeys: 1,
295
+ extractedKeys: [],
296
+ dynamicKeys: [],
297
+ languages: ['en'],
298
+ missing: { en: [] },
299
+ unused: { en: [] },
300
+ summary: {
301
+ totalMissing: 0,
302
+ totalUnused: 0,
303
+ coveragePercent: { en: 100 },
304
+ },
305
+ };
306
+
307
+ const outputPath = path.join(tempDir, 'formatted.json');
308
+ writeReport(report, outputPath);
309
+
310
+ const content = fs.readFileSync(outputPath, 'utf-8');
311
+ expect(content).toContain('\n'); // Has newlines
312
+ expect(content).toContain(' '); // Has indentation
313
+ });
314
+ });
@@ -0,0 +1,179 @@
1
+ import type { ExtractedKey } from './types';
2
+
3
+ /**
4
+ * Parse a full translation key into namespace and local key parts
5
+ */
6
+ export function parseKey(
7
+ fullKey: string,
8
+ defaultNamespace: string = 'translation'
9
+ ): { namespace: string; localKey: string } {
10
+ // Handle namespace:key format (i18next standard)
11
+ if (fullKey.includes(':')) {
12
+ const [namespace, ...rest] = fullKey.split(':');
13
+ return { namespace, localKey: rest.join(':') };
14
+ }
15
+
16
+ // Handle namespace.key format (first segment is namespace if multiple segments)
17
+ const segments = fullKey.split('.');
18
+ if (segments.length > 1) {
19
+ return { namespace: segments[0], localKey: segments.slice(1).join('.') };
20
+ }
21
+
22
+ // Single key without namespace
23
+ return { namespace: defaultNamespace, localKey: fullKey };
24
+ }
25
+
26
+ /**
27
+ * Registry for collecting translation keys across files during build
28
+ */
29
+ export class KeyRegistry {
30
+ private keys: Map<string, ExtractedKey[]> = new Map();
31
+
32
+ /**
33
+ * Add a key to the registry
34
+ */
35
+ addKey(key: ExtractedKey): void {
36
+ const existing = this.keys.get(key.key) || [];
37
+ existing.push(key);
38
+ this.keys.set(key.key, existing);
39
+ }
40
+
41
+ /**
42
+ * Get all keys (including duplicates from different files)
43
+ */
44
+ getAllKeys(): ExtractedKey[] {
45
+ return Array.from(this.keys.values()).flat();
46
+ }
47
+
48
+ /**
49
+ * Get static keys only (excludes dynamic keys)
50
+ */
51
+ getStaticKeys(): ExtractedKey[] {
52
+ return this.getAllKeys().filter((k) => !k.isDynamic);
53
+ }
54
+
55
+ /**
56
+ * Get dynamic keys only
57
+ */
58
+ getDynamicKeys(): ExtractedKey[] {
59
+ return this.getAllKeys().filter((k) => k.isDynamic);
60
+ }
61
+
62
+ /**
63
+ * Get unique static key strings
64
+ */
65
+ getUniqueKeys(): string[] {
66
+ return Array.from(new Set(this.getStaticKeys().map((k) => k.key)));
67
+ }
68
+
69
+ /**
70
+ * Get keys grouped by namespace
71
+ */
72
+ getKeysByNamespace(): Record<string, ExtractedKey[]> {
73
+ const result: Record<string, ExtractedKey[]> = {};
74
+ for (const key of this.getAllKeys()) {
75
+ if (!result[key.namespace]) {
76
+ result[key.namespace] = [];
77
+ }
78
+ result[key.namespace].push(key);
79
+ }
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * Check if a key exists
85
+ */
86
+ hasKey(key: string): boolean {
87
+ return this.keys.has(key);
88
+ }
89
+
90
+ /**
91
+ * Get usage locations for a specific key
92
+ */
93
+ getKeyUsages(key: string): ExtractedKey[] {
94
+ return this.keys.get(key) || [];
95
+ }
96
+
97
+ /**
98
+ * Get the count of unique keys
99
+ */
100
+ getKeyCount(): number {
101
+ return this.keys.size;
102
+ }
103
+
104
+ /**
105
+ * Clear all keys
106
+ */
107
+ clear(): void {
108
+ this.keys.clear();
109
+ }
110
+
111
+ /**
112
+ * Export keys to a plain object for serialization
113
+ */
114
+ toJSON(): { keys: ExtractedKey[]; uniqueCount: number } {
115
+ return {
116
+ keys: this.getAllKeys(),
117
+ uniqueCount: this.getKeyCount(),
118
+ };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Get line number from character index in source code
124
+ */
125
+ export function getLineNumber(code: string, index: number): number {
126
+ return code.slice(0, index).split('\n').length;
127
+ }
128
+
129
+ /**
130
+ * Get column number from character index in source code
131
+ */
132
+ export function getColumnNumber(code: string, index: number): number {
133
+ const lastNewline = code.lastIndexOf('\n', index);
134
+ return index - lastNewline;
135
+ }
136
+
137
+ /**
138
+ * Simple regex-based key extraction (fallback for non-Babel usage)
139
+ */
140
+ export function extractKeysFromSource(
141
+ code: string,
142
+ filename: string,
143
+ defaultNamespace: string = 'translation'
144
+ ): ExtractedKey[] {
145
+ const keys: ExtractedKey[] = [];
146
+
147
+ // Match t('key') or t("key")
148
+ const tCallRegex = /\bt\s*\(\s*['"]([^'"]+)['"]/g;
149
+ let match;
150
+ while ((match = tCallRegex.exec(code)) !== null) {
151
+ const { namespace, localKey } = parseKey(match[1], defaultNamespace);
152
+ keys.push({
153
+ key: match[1],
154
+ namespace,
155
+ localKey,
156
+ file: filename,
157
+ line: getLineNumber(code, match.index),
158
+ column: getColumnNumber(code, match.index),
159
+ isDynamic: false,
160
+ });
161
+ }
162
+
163
+ // Match i18nKey="key" (Trans component)
164
+ const transRegex = /i18nKey\s*=\s*['"]([^'"]+)['"]/g;
165
+ while ((match = transRegex.exec(code)) !== null) {
166
+ const { namespace, localKey } = parseKey(match[1], defaultNamespace);
167
+ keys.push({
168
+ key: match[1],
169
+ namespace,
170
+ localKey,
171
+ file: filename,
172
+ line: getLineNumber(code, match.index),
173
+ column: getColumnNumber(code, match.index),
174
+ isDynamic: false,
175
+ });
176
+ }
177
+
178
+ return keys;
179
+ }
@@ -0,0 +1,21 @@
1
+ // Export types
2
+ export type {
3
+ TranslatePluginOptions,
4
+ ExtractedKey,
5
+ MissingTranslation,
6
+ CoverageStats,
7
+ TranslationsReport,
8
+ LoadedTranslations,
9
+ } from './types';
10
+
11
+ // Export utilities
12
+ export { KeyRegistry, parseKey, extractKeysFromSource } from './extractor';
13
+ export {
14
+ generateReport,
15
+ writeReport,
16
+ loadTranslations,
17
+ printReportSummary,
18
+ } from './reporter';
19
+
20
+ // Note: The actual Babel plugin is in plugin.js (CommonJS)
21
+ // Import it via: import plugin from '@idealyst/translate/plugin'