@abdess76/i18nkit 1.0.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +309 -0
  4. package/bin/cli.js +48 -0
  5. package/bin/commands/apply.js +48 -0
  6. package/bin/commands/check-sync.js +35 -0
  7. package/bin/commands/extract-utils.js +216 -0
  8. package/bin/commands/extract.js +198 -0
  9. package/bin/commands/find-orphans.js +36 -0
  10. package/bin/commands/help.js +34 -0
  11. package/bin/commands/index.js +79 -0
  12. package/bin/commands/translate.js +51 -0
  13. package/bin/commands/version.js +17 -0
  14. package/bin/commands/watch.js +34 -0
  15. package/bin/core/applier-utils.js +144 -0
  16. package/bin/core/applier.js +165 -0
  17. package/bin/core/args.js +147 -0
  18. package/bin/core/backup.js +74 -0
  19. package/bin/core/command-interface.js +69 -0
  20. package/bin/core/config.js +108 -0
  21. package/bin/core/context.js +86 -0
  22. package/bin/core/detector.js +152 -0
  23. package/bin/core/file-walker.js +159 -0
  24. package/bin/core/fs-adapter.js +56 -0
  25. package/bin/core/help-generator.js +208 -0
  26. package/bin/core/index.js +63 -0
  27. package/bin/core/json-utils.js +213 -0
  28. package/bin/core/key-generator.js +75 -0
  29. package/bin/core/log-utils.js +26 -0
  30. package/bin/core/orphan-finder.js +208 -0
  31. package/bin/core/parser-utils.js +187 -0
  32. package/bin/core/paths.js +60 -0
  33. package/bin/core/plugin-interface.js +83 -0
  34. package/bin/core/plugin-resolver-utils.js +166 -0
  35. package/bin/core/plugin-resolver.js +211 -0
  36. package/bin/core/sync-checker-utils.js +99 -0
  37. package/bin/core/sync-checker.js +199 -0
  38. package/bin/core/translator.js +197 -0
  39. package/bin/core/types.js +297 -0
  40. package/bin/core/watcher.js +119 -0
  41. package/bin/plugins/adapter-transloco.js +156 -0
  42. package/bin/plugins/parser-angular.js +56 -0
  43. package/bin/plugins/parser-primeng.js +79 -0
  44. package/bin/plugins/parser-typescript.js +66 -0
  45. package/bin/plugins/provider-deepl.js +65 -0
  46. package/bin/plugins/provider-mymemory.js +192 -0
  47. package/package.json +123 -0
  48. package/types/index.d.ts +85 -0
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Output formatters for sync-checker results.
5
+ * @module sync-checker-utils
6
+ */
7
+
8
+ const { logListWithLimit } = require('./log-utils');
9
+
10
+ function logMissingKeys(missingByLang, log) {
11
+ for (const [lang, keys] of Object.entries(missingByLang)) {
12
+ if (keys.length === 0) {
13
+ continue;
14
+ }
15
+ logListWithLimit({ items: keys, label: `Missing in ${lang}.json`, limit: 10, log });
16
+ log();
17
+ }
18
+ }
19
+
20
+ function logIdenticalValues(identicalValues, log) {
21
+ if (identicalValues.length === 0) {
22
+ return;
23
+ }
24
+ const truncate = v => (typeof v === 'string' && v.length > 40 ? `${v.substring(0, 37)}...` : v);
25
+ logListWithLimit({
26
+ items: identicalValues,
27
+ label: 'Identical values across languages',
28
+ limit: 10,
29
+ log,
30
+ formatter: ({ key, value }) => `${key}: "${truncate(value)}"`,
31
+ });
32
+ log();
33
+ }
34
+
35
+ function logIcuMismatches(icuMismatches, log) {
36
+ if (icuMismatches.length === 0) {
37
+ return;
38
+ }
39
+ log(`ICU structure mismatches (${icuMismatches.length}):`);
40
+ icuMismatches.slice(0, 5).forEach(({ key, hasIcu, missingIcu }) => {
41
+ log(` [!] ${key}`);
42
+ log(` Has ICU: ${hasIcu.join(', ')} | Missing ICU: ${missingIcu.join(', ')}`);
43
+ });
44
+ if (icuMismatches.length > 5) {
45
+ log(` ... and ${icuMismatches.length - 5} more`);
46
+ }
47
+ log();
48
+ }
49
+
50
+ function logIcuMessages(icuMessages, log) {
51
+ if (icuMessages.length === 0) {
52
+ return;
53
+ }
54
+ logListWithLimit({
55
+ items: icuMessages,
56
+ label: 'ICU/Pluralization messages',
57
+ limit: 5,
58
+ log,
59
+ formatter: ({ key }) => key,
60
+ });
61
+ log();
62
+ }
63
+
64
+ function logSyncIssues(sr, log) {
65
+ logMissingKeys(sr.missingByLang, log);
66
+ logIdenticalValues(sr.identicalValues, log);
67
+ logIcuMismatches(sr.icuMismatches, log);
68
+ logIcuMessages(sr.icuMessages, log);
69
+ }
70
+
71
+ function logSyncSummary(sr, log) {
72
+ log('Summary');
73
+ log('-'.repeat(50));
74
+ log(`Total keys: ${sr.allKeys.size}`);
75
+ log(`Languages: ${sr.langFiles.length}`);
76
+ log(`Missing keys: ${Object.values(sr.missingByLang).reduce((a, b) => a + b.length, 0)}`);
77
+ log(`Identical values: ${sr.identicalValues.length}`);
78
+ log(`ICU messages: ${sr.icuMessages.length}`);
79
+ log(`ICU mismatches: ${sr.icuMismatches.length}`);
80
+ }
81
+
82
+ function logSyncHeader(log) {
83
+ log('Transloco Sync Check');
84
+ log('='.repeat(50));
85
+ }
86
+
87
+ function handleInsufficientLangs(langFiles, log) {
88
+ log(
89
+ `Need at least 2 language files to compare.\nFound: ${langFiles.map(f => f.name).join(', ') || 'none'}`,
90
+ );
91
+ return { success: true, langFiles, result: null };
92
+ }
93
+
94
+ module.exports = {
95
+ logSyncIssues,
96
+ logSyncSummary,
97
+ logSyncHeader,
98
+ handleInsufficientLangs,
99
+ };
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Cross-language file synchronization checker.
5
+ * Detects missing keys, identical values, and ICU format mismatches.
6
+ * @module sync-checker
7
+ */
8
+
9
+ const path = require('path');
10
+ const fs = require('./fs-adapter');
11
+ const {
12
+ readJsonFile,
13
+ flattenKeys,
14
+ getNestedValue,
15
+ isICUMessage,
16
+ normalizeData,
17
+ } = require('./json-utils');
18
+ const {
19
+ logSyncIssues,
20
+ logSyncSummary,
21
+ logSyncHeader,
22
+ handleInsufficientLangs,
23
+ } = require('./sync-checker-utils');
24
+
25
+ const isTranslationFile = entry =>
26
+ entry.isFile() &&
27
+ entry.name.endsWith('.json') &&
28
+ !entry.name.includes('report') &&
29
+ !entry.name.includes('extracted');
30
+
31
+ async function readI18nDirectory(i18nDir) {
32
+ try {
33
+ return await fs.readdir(i18nDir, { withFileTypes: true });
34
+ } catch {
35
+ throw new Error(`i18n directory not found: ${i18nDir}`);
36
+ }
37
+ }
38
+
39
+ async function loadLangFilesForSync(i18nDir) {
40
+ const dirEntries = await readI18nDirectory(i18nDir);
41
+ return dirEntries
42
+ .filter(isTranslationFile)
43
+ .map(f => ({ name: f.name.replace('.json', ''), path: path.join(i18nDir, f.name) }));
44
+ }
45
+
46
+ async function normalizeLangData(langFiles, format) {
47
+ const langData = {};
48
+ const allKeys = new Set();
49
+ const results = await Promise.all(
50
+ langFiles.map(async ({ name, path: filePath }) => {
51
+ const data = await readJsonFile(filePath);
52
+ if (!data) {
53
+ throw new Error(`Cannot parse ${name}.json`);
54
+ }
55
+ return { name, data };
56
+ }),
57
+ );
58
+ for (const { name, data } of results) {
59
+ const normalized = normalizeData(data, format);
60
+ const keys = flattenKeys(normalized);
61
+ langData[name] = { data: normalized, keys: new Set(keys) };
62
+ keys.forEach(k => allKeys.add(k));
63
+ }
64
+ return { langData, allKeys };
65
+ }
66
+
67
+ function getKeyEntries(key, langFiles, langData) {
68
+ return langFiles
69
+ .filter(({ name }) => langData[name].keys.has(key))
70
+ .map(({ name }) => [name, getNestedValue(langData[name].data, key.split('.'))]);
71
+ }
72
+
73
+ function detectIcuStatus(entries) {
74
+ return {
75
+ icuLangs: entries.filter(([, v]) => isICUMessage(v)).map(([l]) => l),
76
+ nonIcuLangs: entries.filter(([, v]) => v && !isICUMessage(v)).map(([l]) => l),
77
+ };
78
+ }
79
+
80
+ const hasIcuMismatch = (icu, nonIcu) => icu.length > 0 && nonIcu.length > 0;
81
+ const hasUniqueValue = entries => new Set(Object.values(Object.fromEntries(entries))).size === 1;
82
+
83
+ function classifyKeyResult(key, entries, icuStatus) {
84
+ const { icuLangs, nonIcuLangs } = icuStatus;
85
+ if (hasIcuMismatch(icuLangs, nonIcuLangs)) {
86
+ return { type: 'icuMismatch', key, hasIcu: icuLangs, missingIcu: nonIcuLangs };
87
+ }
88
+ if (icuLangs.length > 0) {
89
+ return { type: 'icuMessage', key, langs: icuLangs };
90
+ }
91
+ if (hasUniqueValue(entries)) {
92
+ return { type: 'identical', key, value: entries[0]?.[1] };
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function analyzeKeyForSync(key, langFiles, langData) {
98
+ const entries = getKeyEntries(key, langFiles, langData);
99
+ if (entries.length < 2) {
100
+ return null;
101
+ }
102
+ return classifyKeyResult(key, entries, detectIcuStatus(entries));
103
+ }
104
+
105
+ const CATEGORY_MAP = {
106
+ icuMismatch: 'icuMismatches',
107
+ icuMessage: 'icuMessages',
108
+ identical: 'identicalValues',
109
+ };
110
+ const addToCategory = (cat, res) => {
111
+ const k = res?.type && CATEGORY_MAP[res.type];
112
+ if (k) {
113
+ cat[k].push(res);
114
+ }
115
+ };
116
+
117
+ function categorizeKeysForSync(allKeys, langFiles, langData) {
118
+ const categories = { identicalValues: [], icuMessages: [], icuMismatches: [] };
119
+ for (const key of allKeys) {
120
+ addToCategory(categories, analyzeKeyForSync(key, langFiles, langData));
121
+ }
122
+ return categories;
123
+ }
124
+
125
+ function buildSyncResult(langFiles, langData, allKeys) {
126
+ const missingByLang = Object.fromEntries(
127
+ langFiles.map(({ name }) => [name, [...allKeys.difference(langData[name].keys)]]),
128
+ );
129
+ const { identicalValues, icuMessages, icuMismatches } = categorizeKeysForSync(
130
+ allKeys,
131
+ langFiles,
132
+ langData,
133
+ );
134
+ return { allKeys, langFiles, missingByLang, identicalValues, icuMessages, icuMismatches };
135
+ }
136
+
137
+ const hasCriticalSyncIssues = sr =>
138
+ Object.values(sr.missingByLang).some(k => k.length > 0) || sr.icuMismatches.length > 0;
139
+
140
+ function buildCheckSyncResult(ctx) {
141
+ const { syncResult, strict, exitCodes, log } = ctx;
142
+ const failed = strict && hasCriticalSyncIssues(syncResult);
143
+ if (failed) {
144
+ log('\nSync check failed (--strict mode)');
145
+ }
146
+ return {
147
+ success: !failed,
148
+ exitCode: failed ? exitCodes.untranslated : exitCodes.success,
149
+ result: syncResult,
150
+ };
151
+ }
152
+
153
+ async function performSyncCheck(langFiles, format, log) {
154
+ log(`Comparing: ${langFiles.map(f => f.name).join(', ')}\n`);
155
+ const { langData, allKeys } = await normalizeLangData(langFiles, format);
156
+ const syncResult = buildSyncResult(langFiles, langData, allKeys);
157
+ logSyncIssues(syncResult, log);
158
+ logSyncSummary(syncResult, log);
159
+ return syncResult;
160
+ }
161
+
162
+ const getExitCodes = options => options.exitCodes || { success: 0, untranslated: 1 };
163
+ const parseSyncOptions = opts => ({
164
+ i18nDir: opts.i18nDir,
165
+ format: opts.format || 'nested',
166
+ log: opts.log || console.log,
167
+ strict: opts.strict || false,
168
+ exitCodes: getExitCodes(opts),
169
+ });
170
+
171
+ /**
172
+ * Compares language files for missing keys, ICU mismatches, and duplicates
173
+ * @param {SyncOptions} [options]
174
+ * @returns {Promise<SyncCheckResult>}
175
+ * @example
176
+ * const { success, result } = await checkSync({ i18nDir: './src/i18n', strict: true });
177
+ * console.log(result.missingByLang);
178
+ */
179
+ async function checkSync(options = {}) {
180
+ const opts = parseSyncOptions(options);
181
+ logSyncHeader(opts.log);
182
+ const langFiles = await loadLangFilesForSync(opts.i18nDir);
183
+ if (langFiles.length < 2) {
184
+ return handleInsufficientLangs(langFiles, opts.log);
185
+ }
186
+ const syncResult = await performSyncCheck(langFiles, opts.format, opts.log);
187
+ return buildCheckSyncResult({
188
+ syncResult,
189
+ strict: opts.strict,
190
+ exitCodes: opts.exitCodes,
191
+ log: opts.log,
192
+ });
193
+ }
194
+
195
+ module.exports = {
196
+ checkSync,
197
+ isTranslationFile,
198
+ readI18nDirectory,
199
+ };
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Automated translation of i18n JSON files.
5
+ * Supports DeepL API and free MyMemory service with duplicate caching.
6
+ * @module translator
7
+ */
8
+
9
+ const path = require('path');
10
+ const { readJsonFile, writeJsonFile, flattenJson, unflattenJson } = require('./json-utils');
11
+
12
+ async function prepareTranslationData(sourceLang, i18nDir) {
13
+ const sourceFile = path.join(i18nDir, `${sourceLang}.json`);
14
+ const sourceJson = await readJsonFile(sourceFile);
15
+ if (!sourceJson) {
16
+ throw new Error(`Source file not found or invalid: ${sourceFile}`);
17
+ }
18
+
19
+ const flat = flattenJson(sourceJson);
20
+ const entries = Object.entries(flat).filter(([, v]) => v && typeof v === 'string');
21
+ const keys = entries.map(([k]) => k);
22
+ const values = entries.map(([, v]) => v);
23
+ const uniqueValues = [...new Set(values)];
24
+
25
+ return { keys, values, uniqueValues };
26
+ }
27
+
28
+ function logTranslateHeader(ctx) {
29
+ const { sourceLang, targetLang, useDeepL, log } = ctx;
30
+ log('Transloco Auto-Translate');
31
+ log('='.repeat(50));
32
+ log(`Source: ${sourceLang}.json -> Target: ${targetLang}.json`);
33
+ log(`Provider: ${useDeepL ? 'DeepL' : 'MyMemory (free)'}`);
34
+ log();
35
+ }
36
+
37
+ function logTranslationSummary(ctx) {
38
+ const { valuesCount, failedCount, useDeepL, email, log } = ctx;
39
+ log('\nSummary');
40
+ log('-'.repeat(50));
41
+ log(`Strings translated: ${valuesCount - failedCount}/${valuesCount}`);
42
+ if (failedCount > 0) {
43
+ log(`Failed translations: ${failedCount} (kept original text)`);
44
+ }
45
+ log(`Provider: ${useDeepL ? 'DeepL API' : 'MyMemory (free)'}`);
46
+ if (!useDeepL && !email) {
47
+ log('Tip: Use --email=your@email.com for higher rate limits');
48
+ }
49
+ }
50
+
51
+ async function executeDeepLTranslation(ctx) {
52
+ const { uniqueValues, sourceLang, targetLang, provider } = ctx;
53
+ const translated = await provider.translateBatch(uniqueValues, sourceLang, targetLang);
54
+ return {
55
+ translationMap: new Map(uniqueValues.map((v, i) => [v, translated[i]])),
56
+ failedCount: 0,
57
+ };
58
+ }
59
+
60
+ function executeMyMemoryTranslation(ctx) {
61
+ const { uniqueValues, sourceLang, targetLang, provider, email, verbose } = ctx;
62
+ return provider.translateBatch(uniqueValues, sourceLang, targetLang, {
63
+ email,
64
+ verbose,
65
+ onProgress: (processed, total) => {
66
+ if (processed < total) {
67
+ process.stdout.write(`\r Progress: ${processed}/${total}`);
68
+ }
69
+ },
70
+ });
71
+ }
72
+
73
+ function executeTranslation(ctx) {
74
+ return ctx.useDeepL ? executeDeepLTranslation(ctx) : executeMyMemoryTranslation(ctx);
75
+ }
76
+
77
+ async function saveTranslationResult(ctx) {
78
+ const { targetJson, targetFile, startTime, dryRun = false, log = console.log } = ctx;
79
+
80
+ if (dryRun) {
81
+ log('\n[DRY RUN] Would write:');
82
+ log(JSON.stringify(targetJson, null, 2));
83
+ return;
84
+ }
85
+
86
+ await writeJsonFile(targetFile, targetJson);
87
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
88
+ log(`\nTranslated: ${targetFile} (${elapsed}s)`);
89
+ }
90
+
91
+ function buildTranslatedOutput(ctx) {
92
+ const { keys, values, translationMap, i18nDir, targetLang } = ctx;
93
+ const translatedFlat = Object.fromEntries(
94
+ keys.map((k, i) => [k, translationMap.get(values[i]) ?? values[i]]),
95
+ );
96
+ return {
97
+ targetJson: unflattenJson(translatedFlat),
98
+ targetFile: path.join(i18nDir, `${targetLang}.json`),
99
+ };
100
+ }
101
+
102
+ function logDuplicateInfo(ctx) {
103
+ const { values, uniqueValues, verbose, log } = ctx;
104
+ if (values.length > uniqueValues.length && verbose) {
105
+ log(` ${values.length - uniqueValues.length} duplicate strings will use cache`);
106
+ }
107
+ }
108
+
109
+ async function performTranslation(ctx) {
110
+ const { uniqueValues, sourceLang, targetLang, provider, useDeepL, email, verbose, log } = ctx;
111
+ log(`Translating ${uniqueValues.length} unique strings...`);
112
+
113
+ const result = await executeTranslation({
114
+ uniqueValues,
115
+ sourceLang,
116
+ targetLang,
117
+ provider,
118
+ useDeepL,
119
+ email,
120
+ verbose,
121
+ });
122
+ log();
123
+ return result;
124
+ }
125
+
126
+ function handleEmptyTranslation(log) {
127
+ log('No strings to translate.');
128
+ return { success: true, translated: 0 };
129
+ }
130
+
131
+ function buildTranslationContext(ctx) {
132
+ const { uniqueValues, sourceLang, targetLang, provider, useDeepL, email, verbose, log } = ctx;
133
+ return { uniqueValues, sourceLang, targetLang, provider, useDeepL, email, verbose, log };
134
+ }
135
+
136
+ function buildOutputContext(ctx, translationMap) {
137
+ const { keys, values, i18nDir, targetLang } = ctx;
138
+ return { keys, values, translationMap, i18nDir, targetLang };
139
+ }
140
+
141
+ async function runTranslation(ctx) {
142
+ logDuplicateInfo(ctx);
143
+ const startTime = Date.now();
144
+ const { translationMap, failedCount } = await performTranslation(buildTranslationContext(ctx));
145
+ const { targetJson, targetFile } = buildTranslatedOutput(buildOutputContext(ctx, translationMap));
146
+ await saveTranslationResult({
147
+ targetJson,
148
+ targetFile,
149
+ startTime,
150
+ dryRun: ctx.dryRun,
151
+ log: ctx.log,
152
+ });
153
+ logTranslationSummary({
154
+ valuesCount: ctx.values.length,
155
+ failedCount,
156
+ useDeepL: ctx.useDeepL,
157
+ email: ctx.email,
158
+ log: ctx.log,
159
+ });
160
+ return { success: true, translated: ctx.values.length - failedCount, failed: failedCount };
161
+ }
162
+
163
+ function parseTranslateOptions(options) {
164
+ const {
165
+ i18nDir,
166
+ provider,
167
+ useDeepL = false,
168
+ email,
169
+ verbose = false,
170
+ dryRun = false,
171
+ log = console.log,
172
+ } = options;
173
+ return { i18nDir, provider, useDeepL, email, verbose, dryRun, log };
174
+ }
175
+
176
+ /**
177
+ * Translates a JSON language file using DeepL or MyMemory
178
+ * @param {string} sourceLang - Source language code (e.g., 'en')
179
+ * @param {string} targetLang - Target language code (e.g., 'fr')
180
+ * @param {TranslateOptions} [options]
181
+ * @returns {Promise<TranslateResult>}
182
+ * @example
183
+ * await translateFile('en', 'fr', { i18nDir: './src/i18n', provider: deepLProvider });
184
+ */
185
+ async function translateFile(sourceLang, targetLang, options = {}) {
186
+ const opts = parseTranslateOptions(options);
187
+ logTranslateHeader({ sourceLang, targetLang, useDeepL: opts.useDeepL, log: opts.log });
188
+ const data = await prepareTranslationData(sourceLang, opts.i18nDir);
189
+ if (data.keys.length === 0) {
190
+ return handleEmptyTranslation(opts.log);
191
+ }
192
+ return runTranslation({ ...data, sourceLang, targetLang, ...opts });
193
+ }
194
+
195
+ module.exports = {
196
+ translateFile,
197
+ };