@scoutello/i18n-magic 0.64.0 → 0.66.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 (58) hide show
  1. package/README.md +13 -7
  2. package/dist/cli.js +20 -15
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/check-missing.d.ts +11 -1
  5. package/dist/commands/check-missing.d.ts.map +1 -1
  6. package/dist/commands/check-missing.js +56 -96
  7. package/dist/commands/check-missing.js.map +1 -1
  8. package/dist/commands/replace.d.ts +6 -0
  9. package/dist/commands/replace.d.ts.map +1 -1
  10. package/dist/commands/replace.js +48 -29
  11. package/dist/commands/replace.js.map +1 -1
  12. package/dist/commands/scan.d.ts.map +1 -1
  13. package/dist/commands/scan.js +114 -80
  14. package/dist/commands/scan.js.map +1 -1
  15. package/dist/commands/sync-locales.d.ts.map +1 -1
  16. package/dist/commands/sync-locales.js +90 -50
  17. package/dist/commands/sync-locales.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/languges.d.ts.map +1 -1
  23. package/dist/lib/languges.js +20 -0
  24. package/dist/lib/languges.js.map +1 -1
  25. package/dist/lib/locale-storage.d.ts +7 -0
  26. package/dist/lib/locale-storage.d.ts.map +1 -0
  27. package/dist/lib/locale-storage.js +49 -0
  28. package/dist/lib/locale-storage.js.map +1 -0
  29. package/dist/lib/translation-response.d.ts +8 -0
  30. package/dist/lib/translation-response.d.ts.map +1 -0
  31. package/dist/lib/translation-response.js +43 -0
  32. package/dist/lib/translation-response.js.map +1 -0
  33. package/dist/lib/types.d.ts +1 -1
  34. package/dist/lib/types.d.ts.map +1 -1
  35. package/dist/lib/update-translation-key.d.ts +16 -0
  36. package/dist/lib/update-translation-key.d.ts.map +1 -0
  37. package/dist/lib/update-translation-key.js +67 -0
  38. package/dist/lib/update-translation-key.js.map +1 -0
  39. package/dist/lib/utils.d.ts +2 -0
  40. package/dist/lib/utils.d.ts.map +1 -1
  41. package/dist/lib/utils.js +69 -51
  42. package/dist/lib/utils.js.map +1 -1
  43. package/dist/mcp-server.js +28 -79
  44. package/dist/mcp-server.js.map +1 -1
  45. package/package.json +5 -9
  46. package/src/cli.ts +26 -18
  47. package/src/commands/check-missing.ts +83 -137
  48. package/src/commands/replace.ts +81 -43
  49. package/src/commands/scan.ts +130 -108
  50. package/src/commands/sync-locales.ts +125 -86
  51. package/src/index.ts +6 -1
  52. package/src/lib/languges.ts +20 -0
  53. package/src/lib/locale-storage.ts +76 -0
  54. package/src/lib/translation-response.ts +59 -0
  55. package/src/lib/types.ts +1 -1
  56. package/src/lib/update-translation-key.ts +119 -0
  57. package/src/lib/utils.ts +92 -76
  58. package/src/mcp-server.ts +31 -112
@@ -1,162 +1,108 @@
1
1
  import type { Configuration } from "../lib/types.js"
2
2
  import {
3
- getMissingKeys,
4
- findExistingTranslations,
3
+ getKeysWithNamespaces,
4
+ getPureKey,
5
5
  loadLocalesFile,
6
- writeLocalesFile,
7
- translateKey,
8
6
  } from "../lib/utils.js"
9
7
 
10
- export const checkMissing = async (config: Configuration) => {
11
- const {
12
- loadPath,
13
- savePath,
14
- defaultLocale,
15
- namespaces,
16
- locales,
17
- context,
18
- openai,
19
- model,
20
- disableTranslationDuringScan,
21
- } = config
22
-
23
- const newKeys = await getMissingKeys(config)
8
+ export interface MissingTranslationsReport {
9
+ missing: Record<string, Record<string, string[]>>
10
+ totalExpected: number
11
+ totalMissing: number
12
+ }
24
13
 
25
- if (newKeys.length === 0) {
26
- console.log("āœ… No missing translations found.")
27
- return
14
+ export class MissingTranslationsError extends Error {
15
+ constructor(public report: MissingTranslationsReport) {
16
+ super(`Found ${report.totalMissing} missing translation(s).`)
17
+ this.name = "MissingTranslationsError"
28
18
  }
19
+ }
29
20
 
30
- console.log(`\nšŸ” Found ${newKeys.length} missing key(s) in default locale (${defaultLocale}):`)
31
-
32
- // Group keys by namespace for clearer output
33
- const keysByNamespace: Record<string, string[]> = {}
34
- for (const key of newKeys) {
35
- for (const ns of key.namespaces) {
36
- if (!keysByNamespace[ns]) {
37
- keysByNamespace[ns] = []
38
- }
39
- keysByNamespace[ns].push(key.key)
40
- }
41
- }
42
-
43
- for (const [namespace, keys] of Object.entries(keysByNamespace)) {
44
- console.log(`\n šŸ“¦ ${namespace}: ${keys.length} missing key(s)`)
45
- for (const key of keys) {
46
- console.log(` - ${key}`)
21
+ export const collectMissingTranslations = async (
22
+ config: Configuration,
23
+ ): Promise<MissingTranslationsReport> => {
24
+ const keysWithNamespaces = await getKeysWithNamespaces({
25
+ globPatterns: config.globPatterns,
26
+ defaultNamespace: config.defaultNamespace,
27
+ })
28
+ const expectedByNamespace = Object.fromEntries(
29
+ config.namespaces.map((namespace) => [namespace, new Set<string>()]),
30
+ ) as Record<string, Set<string>>
31
+
32
+ for (const { key, namespaces } of keysWithNamespaces) {
33
+ for (const namespace of namespaces) {
34
+ if (!expectedByNamespace[namespace]) continue
35
+
36
+ const pureKey = getPureKey(
37
+ key,
38
+ namespace,
39
+ namespace === config.defaultNamespace,
40
+ )
41
+ const expectedKey = pureKey || (!key.includes(":") ? key : null)
42
+ if (expectedKey !== null) expectedByNamespace[namespace].add(expectedKey)
47
43
  }
48
44
  }
49
45
 
50
- // Try to auto-fill from other namespaces (like scan does)
51
- const keysList = newKeys.map((k) => k.key)
52
- const existingTranslationResults = await findExistingTranslations(
53
- keysList,
54
- namespaces,
55
- defaultLocale,
56
- loadPath,
46
+ const loadedFiles = await Promise.all(
47
+ config.locales.flatMap((locale) =>
48
+ config.namespaces.map(async (namespace) => ({
49
+ locale,
50
+ namespace,
51
+ translations: await loadLocalesFile(
52
+ config.loadPath,
53
+ locale,
54
+ namespace,
55
+ { silent: true },
56
+ ),
57
+ })),
58
+ ),
57
59
  )
58
60
 
59
- const keysWithValues: Array<{
60
- key: string
61
- namespaces: string[]
62
- value: string | null
63
- }> = []
61
+ const missing: Record<string, Record<string, string[]>> = {}
62
+ let totalMissing = 0
64
63
 
65
- const reusedKeys: string[] = []
66
- const stillMissingKeys: string[] = []
64
+ for (const { locale, namespace, translations } of loadedFiles) {
65
+ const missingKeys = Array.from(expectedByNamespace[namespace]).filter(
66
+ (key) => !Object.hasOwn(translations, key),
67
+ )
68
+ if (missingKeys.length === 0) continue
67
69
 
68
- for (const newKey of newKeys) {
69
- const existingValue = existingTranslationResults[newKey.key]
70
-
71
- if (existingValue !== null) {
72
- reusedKeys.push(newKey.key)
73
- keysWithValues.push({
74
- key: newKey.key,
75
- namespaces: newKey.namespaces,
76
- value: existingValue,
77
- })
78
- } else {
79
- stillMissingKeys.push(newKey.key)
80
- keysWithValues.push({
81
- key: newKey.key,
82
- namespaces: newKey.namespaces,
83
- value: null,
84
- })
85
- }
70
+ missing[locale] ??= {}
71
+ missing[locale][namespace] = missingKeys.sort()
72
+ totalMissing += missingKeys.length
86
73
  }
87
74
 
88
- // If we can auto-fill some keys, do it
89
- if (reusedKeys.length > 0) {
90
- console.log(`\nšŸ”„ Auto-reusing ${reusedKeys.length} existing value(s) from other namespaces...`)
91
-
92
- const keysToWrite = keysWithValues.filter((k) => k.value !== null)
93
- const newKeysObject = keysToWrite.reduce((prev, next) => {
94
- prev[next.key] = next.value as string
95
- return prev
96
- }, {} as Record<string, string>)
97
-
98
- const allLocales = disableTranslationDuringScan ? [defaultLocale] : locales
75
+ return {
76
+ missing,
77
+ totalExpected: Object.values(expectedByNamespace).reduce(
78
+ (total, keys) => total + keys.size,
79
+ 0,
80
+ ),
81
+ totalMissing,
82
+ }
83
+ }
99
84
 
100
- // Batch translate for all non-default locales in parallel
101
- const translationCache: Record<string, Record<string, string>> = {
102
- [defaultLocale]: newKeysObject,
103
- }
85
+ const printMissingTranslationsReport = (report: MissingTranslationsReport) => {
86
+ if (report.totalMissing === 0) {
87
+ console.log("āœ… No missing translations found.")
88
+ return
89
+ }
104
90
 
105
- const nonDefaultLocales = allLocales.filter((l) => l !== defaultLocale)
106
- if (nonDefaultLocales.length > 0 && !disableTranslationDuringScan) {
107
- console.log(` šŸ“ Translating to ${nonDefaultLocales.length} other locale(s)...`)
108
- await Promise.all(
109
- nonDefaultLocales.map(async (locale) => {
110
- const translatedValues = await translateKey({
111
- inputLanguage: defaultLocale,
112
- outputLanguage: locale,
113
- context,
114
- object: newKeysObject,
115
- openai,
116
- model,
117
- })
118
- translationCache[locale] = translatedValues
119
- })
120
- )
91
+ console.error(`āŒ Found ${report.totalMissing} missing translation(s):`)
92
+ for (const [locale, namespaces] of Object.entries(report.missing)) {
93
+ for (const [namespace, keys] of Object.entries(namespaces)) {
94
+ console.error(` ${locale}/${namespace}: ${keys.join(", ")}`)
121
95
  }
122
-
123
- // Process all locale/namespace combinations in parallel
124
- await Promise.all(
125
- allLocales.flatMap((locale) =>
126
- namespaces.map(async (namespace) => {
127
- const existingKeys = await loadLocalesFile(loadPath, locale, namespace)
128
-
129
- const relevantKeys = keysToWrite.filter((key) =>
130
- key.namespaces?.includes(namespace),
131
- )
132
-
133
- if (relevantKeys.length === 0) {
134
- return
135
- }
136
-
137
- const translatedValues = translationCache[locale]
138
- for (const key of relevantKeys) {
139
- existingKeys[key.key] = translatedValues[key.key]
140
- }
141
-
142
- await writeLocalesFile(savePath, locale, namespace, existingKeys)
143
- console.log(` āœ… Updated ${locale}/${namespace}.json with ${relevantKeys.length} key(s)`)
144
- })
145
- )
146
- )
147
-
148
- console.log(`\nāœ… Successfully auto-filled ${reusedKeys.length} key(s) from existing translations.`)
149
96
  }
97
+ }
150
98
 
151
- // Now check if there are still missing keys that couldn't be auto-filled
152
- if (stillMissingKeys.length > 0) {
153
- console.error(`\nāŒ Error: ${stillMissingKeys.length} translation(s) could not be auto-filled:`)
154
- for (const key of stillMissingKeys) {
155
- console.error(` - ${key}`)
156
- }
157
- console.error(`\nPlease run 'i18n-magic scan' to provide values for these keys.`)
158
- process.exit(1)
99
+ export const checkMissing = async (config: Configuration) => {
100
+ const report = await collectMissingTranslations(config)
101
+ printMissingTranslationsReport(report)
102
+
103
+ if (report.totalMissing > 0) {
104
+ throw new MissingTranslationsError(report)
159
105
  }
160
106
 
161
- console.log("\nāœ… All translations are complete.")
107
+ return report
162
108
  }
@@ -27,20 +27,88 @@ const getKeyToReplace = async (
27
27
  return { key: keyToReplace, namespaces }
28
28
  }
29
29
 
30
+ export const replaceTranslationValue = async ({
31
+ config,
32
+ key,
33
+ value,
34
+ targetNamespaces,
35
+ }: {
36
+ config: Configuration
37
+ key: string
38
+ value: string
39
+ targetNamespaces: string[]
40
+ }) => {
41
+ const { loadPath, savePath, defaultLocale, locales, context, openai } = config
42
+ const stagedFiles = new Map<string, Record<string, string>>()
43
+ await Promise.all(
44
+ targetNamespaces.flatMap((namespace) =>
45
+ locales.map(async (locale) => {
46
+ const existingKeys = await loadLocalesFile(
47
+ loadPath,
48
+ locale,
49
+ namespace,
50
+ )
51
+ stagedFiles.set(JSON.stringify([locale, namespace]), {
52
+ ...existingKeys,
53
+ })
54
+ }),
55
+ ),
56
+ )
57
+
58
+ const translationCache: Record<string, string> = {
59
+ [defaultLocale]: value,
60
+ }
61
+ await Promise.all(
62
+ locales
63
+ .filter((locale) => locale !== defaultLocale)
64
+ .map(async (locale) => {
65
+ const translation = await translateKey({
66
+ context,
67
+ inputLanguage: defaultLocale,
68
+ outputLanguage: locale,
69
+ object: { [key]: value },
70
+ openai,
71
+ model: config.model,
72
+ })
73
+ translationCache[locale] = translation[key]
74
+ }),
75
+ )
76
+
77
+ for (const namespace of targetNamespaces) {
78
+ for (const locale of locales) {
79
+ const staged = stagedFiles.get(JSON.stringify([locale, namespace]))
80
+ if (!staged) {
81
+ throw new Error(
82
+ `Internal error: no staged snapshot for ${locale}/${namespace}.`,
83
+ )
84
+ }
85
+ staged[key] = translationCache[locale]
86
+ }
87
+ }
88
+
89
+ for (const namespace of targetNamespaces) {
90
+ for (const locale of locales) {
91
+ await writeLocalesFile(
92
+ savePath,
93
+ locale,
94
+ namespace,
95
+ stagedFiles.get(JSON.stringify([locale, namespace]))!,
96
+ )
97
+ }
98
+ }
99
+ }
100
+
30
101
  export const replaceTranslation = async (
31
102
  config: Configuration,
32
103
  key?: string,
33
104
  ) => {
34
105
  const {
35
106
  loadPath,
36
- savePath,
37
107
  defaultLocale,
38
108
  defaultNamespace,
39
109
  namespaces,
40
110
  locales,
41
111
  globPatterns,
42
- context,
43
- openai,
44
112
  } = config
45
113
 
46
114
  // Find all keys with their namespaces from the codebase
@@ -59,7 +127,7 @@ export const replaceTranslation = async (
59
127
  namespaces.map(async (namespace) => ({
60
128
  namespace,
61
129
  keys: await loadLocalesFile(loadPath, defaultLocale, namespace),
62
- }))
130
+ })),
63
131
  )
64
132
 
65
133
  for (const { namespace, keys } of namespaceKeysResults) {
@@ -115,7 +183,7 @@ export const replaceTranslation = async (
115
183
  targetNamespaces.map(async (namespace) => {
116
184
  const keys = await loadLocalesFile(loadPath, defaultLocale, namespace)
117
185
  return { namespace, value: keys[keyToReplace] }
118
- })
186
+ }),
119
187
  )
120
188
 
121
189
  for (const { namespace, value } of currentTranslations) {
@@ -127,44 +195,14 @@ export const replaceTranslation = async (
127
195
  }
128
196
 
129
197
  const newTranslation = await getTextInput("Enter the new translation: ")
198
+ await replaceTranslationValue({
199
+ config,
200
+ key: keyToReplace,
201
+ value: newTranslation,
202
+ targetNamespaces,
203
+ })
130
204
 
131
- // Batch translate for all non-default locales first
132
- const translationCache: Record<string, string> = {
133
- [defaultLocale]: newTranslation,
134
- }
135
-
136
- const nonDefaultLocales = locales.filter((l) => l !== defaultLocale)
137
- if (nonDefaultLocales.length > 0) {
138
- await Promise.all(
139
- nonDefaultLocales.map(async (locale) => {
140
- const translation = await translateKey({
141
- context,
142
- inputLanguage: defaultLocale,
143
- outputLanguage: locale,
144
- object: {
145
- [keyToReplace]: newTranslation,
146
- },
147
- openai,
148
- model: config.model,
149
- })
150
- translationCache[locale] = translation[keyToReplace]
151
- })
152
- )
153
- }
154
-
155
- // Update the key in all relevant namespaces and locales in parallel
156
- await Promise.all(
157
- targetNamespaces.flatMap((namespace) =>
158
- locales.map(async (locale) => {
159
- const newValue = translationCache[locale]
160
- const existingKeys = await loadLocalesFile(loadPath, locale, namespace)
161
- existingKeys[keyToReplace] = newValue
162
- await writeLocalesFile(savePath, locale, namespace, existingKeys)
163
-
164
- console.log(
165
- `Updated "${keyToReplace}" in ${locale} (${namespace}): "${newValue}"`,
166
- )
167
- })
168
- )
205
+ console.log(
206
+ `Updated "${keyToReplace}" in ${targetNamespaces.length} namespace(s) and ${locales.length} locale(s).`,
169
207
  )
170
208
  }