@scoutello/i18n-magic 0.14.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.
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { Command } from "commander"
2
+ import dotenv from "dotenv"
3
+ import OpenAI from "openai"
4
+ import { checkMissing } from "./commands/check-missing"
5
+ import { replaceTranslation } from "./commands/replace"
6
+ import { translateMissing } from "./commands/scan"
7
+ import { syncLocales } from "./commands/sync-locales"
8
+ import type { CommandType, Configuration } from "./lib/types"
9
+ import { loadConfig } from "./lib/utils"
10
+
11
+ // Only run CLI initialization when this file is executed directly
12
+
13
+ const program = new Command()
14
+
15
+ program
16
+ .name("i18n-magic")
17
+ .description(
18
+ "CLI to help you manage your locales JSON with translations, replacements, etc. with OpenAI.",
19
+ )
20
+ .version("0.2.0")
21
+ .option("-c, --config <path>", "path to config file")
22
+ .option("-e, --env <path>", "path to .env file")
23
+
24
+ const commands: CommandType[] = [
25
+ {
26
+ name: "scan",
27
+ description:
28
+ "Scan for missing translations, get prompted for each, translate it to the other locales and save it to the JSON file.",
29
+ action: translateMissing,
30
+ },
31
+ {
32
+ name: "replace",
33
+ description:
34
+ "Replace a translation based on the key, and translate it to the other locales and save it to the JSON file.",
35
+ action: replaceTranslation,
36
+ },
37
+ {
38
+ name: "check-missing",
39
+ description:
40
+ "Check if there are any missing translations. Useful for a CI/CD pipeline or husky hook.",
41
+ action: checkMissing,
42
+ },
43
+ {
44
+ name: "sync",
45
+ description:
46
+ "Sync the translations from the default locale to the other locales. Useful for a CI/CD pipeline or husky hook.",
47
+ action: syncLocales,
48
+ },
49
+ ]
50
+
51
+ for (const command of commands) {
52
+ program
53
+ .command(command.name)
54
+ .description(command.description)
55
+ .action(async () => {
56
+ const res = dotenv.config({
57
+ path: program.opts().env || ".env",
58
+ })
59
+
60
+ const config: Configuration = await loadConfig({
61
+ configPath: program.opts().config,
62
+ })
63
+
64
+ const isGemini = (config.model as string)?.includes("gemini")
65
+
66
+ // Get API key from environment or config
67
+ const openaiKey = res.parsed.OPENAI_API_KEY || config.OPENAI_API_KEY
68
+ const geminiKey = res.parsed.GEMINI_API_KEY || config.GEMINI_API_KEY
69
+
70
+ // Select appropriate key based on model type
71
+ const key = isGemini ? geminiKey : openaiKey
72
+
73
+ if (!key) {
74
+ const keyType = isGemini ? "GEMINI_API_KEY" : "OPENAI_API_KEY"
75
+ console.error(
76
+ `Please provide a${isGemini ? " Gemini" : "n OpenAI"} API key in your .env file or config, called ${keyType}.`,
77
+ )
78
+ process.exit(1)
79
+ }
80
+
81
+ const openai = new OpenAI({
82
+ apiKey: key,
83
+ ...(isGemini && {
84
+ baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
85
+ }),
86
+ })
87
+
88
+ command.action({ ...config, openai })
89
+ })
90
+ }
91
+
92
+ program.parse(process.argv)
@@ -0,0 +1,142 @@
1
+ export const languages = [
2
+ {
3
+ label: "Deutsch",
4
+ name: "German",
5
+ value: "de",
6
+ },
7
+ {
8
+ label: "English",
9
+ name: "English",
10
+ value: "en",
11
+ },
12
+ {
13
+ label: "Español",
14
+ name: "Spanish",
15
+ value: "es",
16
+ },
17
+ {
18
+ label: "Français",
19
+ name: "French",
20
+ value: "fr",
21
+ },
22
+ {
23
+ label: "Dansk",
24
+ name: "Danish",
25
+ value: "dk",
26
+ },
27
+ {
28
+ label: "中文",
29
+ name: "Chinese",
30
+ value: "cn",
31
+ },
32
+ {
33
+ label: "Русский",
34
+ name: "Russian",
35
+ value: "ru",
36
+ },
37
+ {
38
+ label: "Italiano",
39
+ name: "Italian",
40
+ value: "it",
41
+ },
42
+ {
43
+ label: "Nederlands",
44
+ name: "Dutch",
45
+ value: "nl",
46
+ },
47
+ {
48
+ label: "Português",
49
+ name: "Portuguese",
50
+ value: "pt",
51
+ },
52
+ {
53
+ label: "Türkçe",
54
+ name: "Turkish",
55
+ value: "tr",
56
+ },
57
+ {
58
+ label: "Polski",
59
+ name: "Polish",
60
+ value: "pl",
61
+ },
62
+ {
63
+ label: "Українська",
64
+ name: "Ukrainian",
65
+ value: "ua",
66
+ },
67
+ {
68
+ label: "Suomi",
69
+ name: "Finnish",
70
+ value: "fi",
71
+ },
72
+ {
73
+ label: "Norsk",
74
+ name: "Norwegian",
75
+ value: "no",
76
+ },
77
+ {
78
+ label: "Svenska",
79
+ name: "Swedish",
80
+ value: "sv",
81
+ },
82
+ {
83
+ label: "Čeština",
84
+ name: "Czech",
85
+ value: "cz",
86
+ },
87
+ {
88
+ label: "Ελληνικά",
89
+ name: "Greek",
90
+ value: "gr",
91
+ },
92
+ {
93
+ label: "日本語",
94
+ name: "Japanese",
95
+ value: "jp",
96
+ },
97
+ {
98
+ label: "한국어",
99
+ name: "Korean",
100
+ value: "kr",
101
+ },
102
+ {
103
+ label: "Română",
104
+ name: "Romanian",
105
+ value: "ro",
106
+ },
107
+ {
108
+ label: "Hrvatski",
109
+ name: "Croatian",
110
+ value: "hr",
111
+ },
112
+ {
113
+ label: "Magyar",
114
+ name: "Hungarian",
115
+ value: "hu",
116
+ },
117
+ {
118
+ label: "Slovensky",
119
+ name: "Slovak",
120
+ value: "sk",
121
+ },
122
+ {
123
+ label: "हिन्दी",
124
+ name: "Hindi",
125
+ value: "hi",
126
+ },
127
+ {
128
+ label: "தமிழ்",
129
+ name: "Tamil",
130
+ value: "ta",
131
+ },
132
+ {
133
+ label: "Bahasa Indonesia",
134
+ name: "Indonesian",
135
+ value: "id",
136
+ },
137
+ {
138
+ label: "Tiếng Việt",
139
+ name: "Vietnamese",
140
+ value: "vn",
141
+ },
142
+ ]
@@ -0,0 +1,38 @@
1
+ import type OpenAI from "openai"
2
+ import type { ChatModel } from "openai/resources/chat/chat"
3
+
4
+ type Model =
5
+ | ChatModel
6
+ | "gemini-2.5-pro-exp-03-25"
7
+ | "gemini-2.0-flash"
8
+ | "gemini-2.0-flash-lite"
9
+
10
+ export interface Configuration {
11
+ loadPath:
12
+ | string
13
+ | ((locale: string, namespace: string) => Promise<Record<string, string>>)
14
+ savePath:
15
+ | string
16
+ | ((
17
+ locale: string,
18
+ namespace: string,
19
+ data: Record<string, string>,
20
+ ) => Promise<void>)
21
+ defaultLocale: string
22
+ defaultNamespace: string
23
+ namespaces: string[]
24
+ locales: string[]
25
+ globPatterns: string[]
26
+ context?: string
27
+ disableTranslation?: boolean
28
+ OPENAI_API_KEY?: string
29
+ GEMINI_API_KEY?: string
30
+ model?: Model
31
+ openai?: OpenAI
32
+ }
33
+
34
+ export interface CommandType {
35
+ name: string
36
+ description: string
37
+ action: (config: Configuration) => Promise<void>
38
+ }
@@ -0,0 +1,320 @@
1
+ import glob from "fast-glob"
2
+ import { Parser } from "i18next-scanner"
3
+ import fs from "node:fs"
4
+ import path from "node:path"
5
+ import type OpenAI from "openai"
6
+ import prompts from "prompts"
7
+ import { languages } from "./languges"
8
+ import type { Configuration } from "./types"
9
+
10
+ export const loadConfig = ({
11
+ configPath = "i18n-magic.js",
12
+ }: { configPath: string }) => {
13
+ const filePath = path.join(process.cwd(), configPath)
14
+
15
+ if (!fs.existsSync(filePath)) {
16
+ console.error("Config file does not exist:", filePath)
17
+ process.exit(1)
18
+ }
19
+
20
+ try {
21
+ const config = require(filePath)
22
+ // Validate config if needed
23
+ return config
24
+ } catch (error) {
25
+ console.error("Error while loading config:", error)
26
+ process.exit(1)
27
+ }
28
+ }
29
+
30
+ export function removeDuplicatesFromArray<T>(arr: T[]): T[] {
31
+ return arr.filter((item, index) => arr.indexOf(item) === index)
32
+ }
33
+
34
+ export const translateKey = async ({
35
+ inputLanguage,
36
+ context,
37
+ object,
38
+ openai,
39
+ outputLanguage,
40
+ model,
41
+ }: {
42
+ object: Record<string, string>
43
+ context: string
44
+ inputLanguage: string
45
+ outputLanguage: string
46
+ model: string
47
+ openai: OpenAI
48
+ }) => {
49
+ // Split object into chunks of 100 keys
50
+ const entries = Object.entries(object)
51
+ const chunks: Array<[string, string][]> = []
52
+
53
+ for (let i = 0; i < entries.length; i += 100) {
54
+ chunks.push(entries.slice(i, i + 100))
55
+ }
56
+
57
+ let result: Record<string, string> = {}
58
+
59
+ const existingInput = languages.find((l) => l.value === inputLanguage)
60
+ const existingOutput = languages.find((l) => l.value === outputLanguage)
61
+
62
+ const input = existingInput?.label || inputLanguage
63
+ const output = existingOutput?.label || outputLanguage
64
+
65
+ // Translate each chunk
66
+ for (const chunk of chunks) {
67
+ const chunkObject = Object.fromEntries(chunk)
68
+ const completion = await openai.beta.chat.completions.parse({
69
+ model,
70
+ messages: [
71
+ {
72
+ content: `You are a bot that translates the values of a locales JSON. ${
73
+ context
74
+ ? `The user provided some additional context or guidelines about what to fill in the blanks: \"${context}\". `
75
+ : ""
76
+ }The user provides you a JSON with a field named "inputLanguage", which defines the language the values of the JSON are defined in. It also has a field named "outputLanguage", which defines the language you should translate the values to. The last field is named "data", which includes the object with the values to translate. The keys of the values should never be changed. You output only a JSON, which has the same keys as the input, but with translated values. I give you an example input: {"inputLanguage": "English", outputLanguage: "German", "keys": {"hello": "Hello", "world": "World"}}. The output should be {"hello": "Hallo", "world": "Welt"}.`,
77
+ role: "system",
78
+ },
79
+ {
80
+ content: JSON.stringify({
81
+ inputLanguage: input,
82
+ outputLanguage: output,
83
+ data: chunkObject,
84
+ }),
85
+ role: "user",
86
+ },
87
+ ],
88
+ response_format: {
89
+ type: "json_object",
90
+ },
91
+ })
92
+
93
+ const translatedChunk = JSON.parse(
94
+ completion.choices[0].message.content,
95
+ ) as Record<string, string>
96
+
97
+ // Merge translated chunk with result
98
+ result = { ...result, ...translatedChunk }
99
+
100
+ // Optional: Add a small delay between chunks to avoid rate limiting
101
+ await new Promise((resolve) => setTimeout(resolve, 100))
102
+ }
103
+
104
+ return result
105
+ }
106
+
107
+ export const loadLocalesFile = async (
108
+ loadPath:
109
+ | string
110
+ | ((locale: string, namespace: string) => Promise<Record<string, string>>),
111
+ locale: string,
112
+ namespace: string,
113
+ ) => {
114
+ if (typeof loadPath === "string") {
115
+ const resolvedPath = loadPath
116
+ .replace("{{lng}}", locale)
117
+ .replace("{{ns}}", namespace)
118
+
119
+ const content = fs.readFileSync(resolvedPath, "utf-8")
120
+ const json = JSON.parse(content)
121
+
122
+ return json as Record<string, string>
123
+ }
124
+
125
+ return loadPath(locale, namespace)
126
+ }
127
+
128
+ export const writeLocalesFile = async (
129
+ savePath:
130
+ | string
131
+ | ((
132
+ locale: string,
133
+ namespace: string,
134
+ data: Record<string, string>,
135
+ ) => Promise<void>),
136
+ locale: string,
137
+ namespace: string,
138
+ data: Record<string, string>,
139
+ ) => {
140
+ if (typeof savePath === "string") {
141
+ const resolvedSavePath = savePath
142
+ .replace("{{lng}}", locale)
143
+ .replace("{{ns}}", namespace)
144
+
145
+ fs.writeFileSync(resolvedSavePath, JSON.stringify(data, null, 2))
146
+
147
+ return
148
+ }
149
+
150
+ await savePath(locale, namespace, data)
151
+ }
152
+
153
+ export const getPureKey = (
154
+ key: string,
155
+ namespace?: string,
156
+ isDefault?: boolean,
157
+ ) => {
158
+ const splitted = key.split(":")
159
+
160
+ if (splitted.length === 1) {
161
+ if (isDefault) {
162
+ return key
163
+ }
164
+
165
+ return null
166
+ }
167
+
168
+ if (splitted[0] === namespace) {
169
+ return splitted[1]
170
+ }
171
+
172
+ return null
173
+ }
174
+
175
+ export const getMissingKeys = async ({
176
+ globPatterns,
177
+ namespaces,
178
+ defaultNamespace,
179
+ defaultLocale,
180
+ loadPath,
181
+ }: Configuration) => {
182
+ const parser = new Parser({
183
+ nsSeparator: false,
184
+ keySeparator: false,
185
+ })
186
+
187
+ const files = await glob([...globPatterns, "!**/node_modules/**"])
188
+
189
+ const keys = []
190
+
191
+ for (const file of files) {
192
+ const content = fs.readFileSync(file, "utf-8")
193
+ parser.parseFuncFromString(content, { list: ["t"] }, (key: string) => {
194
+ keys.push(key)
195
+ })
196
+ }
197
+
198
+ const uniqueKeys = removeDuplicatesFromArray(keys)
199
+
200
+ const newKeys = []
201
+
202
+ for (const namespace of namespaces) {
203
+ const existingKeys = await loadLocalesFile(
204
+ loadPath,
205
+ defaultLocale,
206
+ namespace,
207
+ )
208
+
209
+ console.log(Object.keys(existingKeys).length, "existing keys")
210
+
211
+ for (const key of uniqueKeys) {
212
+ const pureKey = getPureKey(key, namespace, namespace === defaultNamespace)
213
+
214
+ if (!pureKey) {
215
+ continue
216
+ }
217
+
218
+ if (!existingKeys[pureKey]) {
219
+ newKeys.push({ key: pureKey, namespace })
220
+ }
221
+ }
222
+ }
223
+
224
+ return newKeys
225
+ }
226
+
227
+ export const getTextInput = async (prompt: string) => {
228
+ const input = await prompts({
229
+ name: "value",
230
+ type: "text",
231
+ message: prompt,
232
+ onState: (state) => {
233
+ if (state.aborted) {
234
+ process.nextTick(() => {
235
+ process.exit(0)
236
+ })
237
+ }
238
+ },
239
+ })
240
+
241
+ return input.value as string
242
+ }
243
+
244
+ export const checkAllKeysExist = async ({
245
+ namespaces,
246
+ defaultLocale,
247
+ loadPath,
248
+ locales,
249
+ context,
250
+ openai,
251
+ savePath,
252
+ disableTranslation,
253
+ model,
254
+ }: Configuration) => {
255
+ if (disableTranslation) {
256
+ return
257
+ }
258
+
259
+ for (const namespace of namespaces) {
260
+ const defaultLocaleKeys = await loadLocalesFile(
261
+ loadPath,
262
+ defaultLocale,
263
+ namespace,
264
+ )
265
+
266
+ for (const locale of locales) {
267
+ if (locale === defaultLocale) continue
268
+
269
+ const localeKeys = await loadLocalesFile(loadPath, locale, namespace)
270
+ const missingKeys: Record<string, string> = {}
271
+
272
+ // Check which keys from default locale are missing in current locale
273
+ for (const [key, value] of Object.entries(defaultLocaleKeys)) {
274
+ if (!localeKeys[key]) {
275
+ missingKeys[key] = value
276
+ }
277
+ }
278
+
279
+ // If there are missing keys, translate them
280
+ if (Object.keys(missingKeys).length > 0) {
281
+ console.log(
282
+ `Found ${Object.keys(missingKeys).length} missing keys in ${locale} (namespace: ${namespace})`,
283
+ )
284
+
285
+ const translatedValues = await translateKey({
286
+ inputLanguage: defaultLocale,
287
+ outputLanguage: locale,
288
+ context,
289
+ object: missingKeys,
290
+ openai,
291
+ model,
292
+ })
293
+
294
+ // Merge translated values with existing ones
295
+ const updatedLocaleKeys = {
296
+ ...localeKeys,
297
+ ...translatedValues,
298
+ }
299
+
300
+ // Save the updated translations
301
+ writeLocalesFile(savePath, locale, namespace, updatedLocaleKeys)
302
+ console.log(
303
+ `✓ Translated and saved missing keys for ${locale} (namespace: ${namespace})`,
304
+ )
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ export class TranslationError extends Error {
311
+ constructor(
312
+ message: string,
313
+ public locale?: string,
314
+ public namespace?: string,
315
+ public cause?: Error,
316
+ ) {
317
+ super(message)
318
+ this.name = "TranslationError"
319
+ }
320
+ }