@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.
- package/README.md +13 -7
- package/dist/cli.js +20 -15
- package/dist/cli.js.map +1 -1
- package/dist/commands/check-missing.d.ts +11 -1
- package/dist/commands/check-missing.d.ts.map +1 -1
- package/dist/commands/check-missing.js +56 -96
- package/dist/commands/check-missing.js.map +1 -1
- package/dist/commands/replace.d.ts +6 -0
- package/dist/commands/replace.d.ts.map +1 -1
- package/dist/commands/replace.js +48 -29
- package/dist/commands/replace.js.map +1 -1
- package/dist/commands/scan.d.ts.map +1 -1
- package/dist/commands/scan.js +114 -80
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/sync-locales.d.ts.map +1 -1
- package/dist/commands/sync-locales.js +90 -50
- package/dist/commands/sync-locales.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/languges.d.ts.map +1 -1
- package/dist/lib/languges.js +20 -0
- package/dist/lib/languges.js.map +1 -1
- package/dist/lib/locale-storage.d.ts +7 -0
- package/dist/lib/locale-storage.d.ts.map +1 -0
- package/dist/lib/locale-storage.js +49 -0
- package/dist/lib/locale-storage.js.map +1 -0
- package/dist/lib/translation-response.d.ts +8 -0
- package/dist/lib/translation-response.d.ts.map +1 -0
- package/dist/lib/translation-response.js +43 -0
- package/dist/lib/translation-response.js.map +1 -0
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/update-translation-key.d.ts +16 -0
- package/dist/lib/update-translation-key.d.ts.map +1 -0
- package/dist/lib/update-translation-key.js +67 -0
- package/dist/lib/update-translation-key.js.map +1 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +69 -51
- package/dist/lib/utils.js.map +1 -1
- package/dist/mcp-server.js +28 -79
- package/dist/mcp-server.js.map +1 -1
- package/package.json +5 -9
- package/src/cli.ts +26 -18
- package/src/commands/check-missing.ts +83 -137
- package/src/commands/replace.ts +81 -43
- package/src/commands/scan.ts +130 -108
- package/src/commands/sync-locales.ts +125 -86
- package/src/index.ts +6 -1
- package/src/lib/languges.ts +20 -0
- package/src/lib/locale-storage.ts +76 -0
- package/src/lib/translation-response.ts +59 -0
- package/src/lib/types.ts +1 -1
- package/src/lib/update-translation-key.ts +119 -0
- package/src/lib/utils.ts +92 -76
- package/src/mcp-server.ts +31 -112
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import type { Configuration } from "./types.js"
|
|
3
|
+
|
|
4
|
+
export class LocaleConfigurationError extends Error {
|
|
5
|
+
constructor(message: string) {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = "LocaleConfigurationError"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const resolveConfiguredLocale = (
|
|
12
|
+
config: Pick<Configuration, "defaultLocale" | "locales">,
|
|
13
|
+
requested?: string,
|
|
14
|
+
) => {
|
|
15
|
+
const locale = requested ?? config.defaultLocale
|
|
16
|
+
|
|
17
|
+
if (!config.locales.includes(locale)) {
|
|
18
|
+
throw new LocaleConfigurationError(
|
|
19
|
+
`Unsupported locale "${locale}". Expected one of: ${config.locales.join(", ")}.`,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return locale
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const getConfiguredBasePath = (template: string) => {
|
|
27
|
+
const placeholderIndexes = [
|
|
28
|
+
template.indexOf("{{lng}}"),
|
|
29
|
+
template.indexOf("{{ns}}"),
|
|
30
|
+
].filter((index) => index >= 0)
|
|
31
|
+
|
|
32
|
+
if (placeholderIndexes.length === 0) {
|
|
33
|
+
return path.dirname(path.resolve(template))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const firstPlaceholder = Math.min(...placeholderIndexes)
|
|
37
|
+
const staticPrefix = template.slice(0, firstPlaceholder)
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
staticPrefix.length === 0 ||
|
|
41
|
+
staticPrefix.endsWith(path.sep) ||
|
|
42
|
+
staticPrefix.endsWith("/") ||
|
|
43
|
+
staticPrefix.endsWith("\\")
|
|
44
|
+
) {
|
|
45
|
+
return path.resolve(staticPrefix || ".")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return path.dirname(path.resolve(staticPrefix))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const resolveLocaleTemplatePath = (
|
|
52
|
+
template: string,
|
|
53
|
+
locale: string,
|
|
54
|
+
namespace: string,
|
|
55
|
+
) => {
|
|
56
|
+
const configuredBase = getConfiguredBasePath(template)
|
|
57
|
+
const expandedPath = template
|
|
58
|
+
.split("{{lng}}")
|
|
59
|
+
.join(locale)
|
|
60
|
+
.split("{{ns}}")
|
|
61
|
+
.join(namespace)
|
|
62
|
+
const resolvedPath = path.resolve(expandedPath)
|
|
63
|
+
const relativePath = path.relative(configuredBase, resolvedPath)
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
relativePath === ".." ||
|
|
67
|
+
relativePath.startsWith(`..${path.sep}`) ||
|
|
68
|
+
path.isAbsolute(relativePath)
|
|
69
|
+
) {
|
|
70
|
+
throw new LocaleConfigurationError(
|
|
71
|
+
"Expanded locale path escapes the configured storage base.",
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return resolvedPath
|
|
76
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export class TranslationResponseError extends Error {
|
|
2
|
+
constructor(message: string) {
|
|
3
|
+
super(message)
|
|
4
|
+
this.name = "TranslationResponseError"
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class TranslationProviderUnavailableError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super(
|
|
11
|
+
"A translation provider is required for this operation, but no provider credentials are configured.",
|
|
12
|
+
)
|
|
13
|
+
this.name = "TranslationProviderUnavailableError"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const parseTranslationResponse = (
|
|
18
|
+
content: string | null,
|
|
19
|
+
requestedKeys: string[],
|
|
20
|
+
): Record<string, string> => {
|
|
21
|
+
let parsed: unknown
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
parsed = content === null ? null : JSON.parse(content)
|
|
25
|
+
} catch {
|
|
26
|
+
throw new TranslationResponseError(
|
|
27
|
+
"Translation provider returned malformed JSON.",
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
32
|
+
throw new TranslationResponseError(
|
|
33
|
+
"Translation provider returned a value that is not a JSON object.",
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const response = parsed as Record<string, unknown>
|
|
38
|
+
const requested = new Set(requestedKeys)
|
|
39
|
+
const responseKeys = Object.keys(response)
|
|
40
|
+
const missingKeys = requestedKeys.filter((key) => !Object.hasOwn(response, key))
|
|
41
|
+
const extraKeys = responseKeys.filter((key) => !requested.has(key))
|
|
42
|
+
const invalidValueKeys = responseKeys.filter(
|
|
43
|
+
(key) => typeof response[key] !== "string",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if (missingKeys.length > 0 || extraKeys.length > 0 || invalidValueKeys.length > 0) {
|
|
47
|
+
const problems: string[] = []
|
|
48
|
+
if (missingKeys.length > 0) problems.push(`missing keys: ${missingKeys.join(", ")}`)
|
|
49
|
+
if (extraKeys.length > 0) problems.push(`extra keys: ${extraKeys.join(", ")}`)
|
|
50
|
+
if (invalidValueKeys.length > 0) {
|
|
51
|
+
problems.push(`non-string values: ${invalidValueKeys.join(", ")}`)
|
|
52
|
+
}
|
|
53
|
+
throw new TranslationResponseError(
|
|
54
|
+
`Translation provider returned an invalid key/value shape (${problems.join("; ")}).`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return response as Record<string, string>
|
|
59
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Configuration } from "./types.js"
|
|
2
|
+
import { resolveConfiguredLocale } from "./locale-storage.js"
|
|
3
|
+
import {
|
|
4
|
+
loadLocalesFile,
|
|
5
|
+
translateKey,
|
|
6
|
+
writeLocalesFile,
|
|
7
|
+
} from "./utils.js"
|
|
8
|
+
|
|
9
|
+
export interface UpdateTranslationKeyResult {
|
|
10
|
+
key: string
|
|
11
|
+
value: string
|
|
12
|
+
sourceLocale: string
|
|
13
|
+
targetNamespaces: string[]
|
|
14
|
+
updatedLocales: string[]
|
|
15
|
+
pendingLocales: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const updateTranslationKeyOperation = async ({
|
|
19
|
+
config,
|
|
20
|
+
key,
|
|
21
|
+
value,
|
|
22
|
+
language,
|
|
23
|
+
}: {
|
|
24
|
+
config: Configuration
|
|
25
|
+
key: string
|
|
26
|
+
value: string
|
|
27
|
+
language?: string
|
|
28
|
+
}): Promise<UpdateTranslationKeyResult> => {
|
|
29
|
+
const sourceLocale = resolveConfiguredLocale(config, language)
|
|
30
|
+
const sourceFiles = await Promise.all(
|
|
31
|
+
config.namespaces.map(async (namespace) => ({
|
|
32
|
+
namespace,
|
|
33
|
+
translations: await loadLocalesFile(
|
|
34
|
+
config.loadPath,
|
|
35
|
+
sourceLocale,
|
|
36
|
+
namespace,
|
|
37
|
+
{ silent: true },
|
|
38
|
+
),
|
|
39
|
+
})),
|
|
40
|
+
)
|
|
41
|
+
const targetNamespaces = sourceFiles
|
|
42
|
+
.filter(({ translations }) => Object.hasOwn(translations, key))
|
|
43
|
+
.map(({ namespace }) => namespace)
|
|
44
|
+
|
|
45
|
+
if (targetNamespaces.length === 0) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Key "${key}" does not exist in the ${sourceLocale} translation files. Use add_translation_key to create it.`,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const pendingLocales = config.openai
|
|
52
|
+
? []
|
|
53
|
+
: config.locales.filter((locale) => locale !== sourceLocale)
|
|
54
|
+
const updatedLocales = config.openai ? [...config.locales] : [sourceLocale]
|
|
55
|
+
const translatedValues: Record<string, string> = {
|
|
56
|
+
[sourceLocale]: value,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (config.openai) {
|
|
60
|
+
await Promise.all(
|
|
61
|
+
config.locales
|
|
62
|
+
.filter((locale) => locale !== sourceLocale)
|
|
63
|
+
.map(async (locale) => {
|
|
64
|
+
const translation = await translateKey({
|
|
65
|
+
context: config.context || "",
|
|
66
|
+
inputLanguage: sourceLocale,
|
|
67
|
+
outputLanguage: locale,
|
|
68
|
+
object: { [key]: value },
|
|
69
|
+
openai: config.openai,
|
|
70
|
+
model: config.model,
|
|
71
|
+
})
|
|
72
|
+
translatedValues[locale] = translation[key]
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const snapshots = new Map<string, Record<string, string>>()
|
|
78
|
+
await Promise.all(
|
|
79
|
+
updatedLocales.flatMap((locale) =>
|
|
80
|
+
targetNamespaces.map(async (namespace) => {
|
|
81
|
+
const sourceSnapshot =
|
|
82
|
+
locale === sourceLocale
|
|
83
|
+
? sourceFiles.find((entry) => entry.namespace === namespace)
|
|
84
|
+
?.translations
|
|
85
|
+
: undefined
|
|
86
|
+
const existingKeys =
|
|
87
|
+
sourceSnapshot ??
|
|
88
|
+
(await loadLocalesFile(config.loadPath, locale, namespace, {
|
|
89
|
+
silent: true,
|
|
90
|
+
}))
|
|
91
|
+
snapshots.set(
|
|
92
|
+
JSON.stringify([locale, namespace]),
|
|
93
|
+
{ ...existingKeys, [key]: translatedValues[locale] },
|
|
94
|
+
)
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
for (const locale of updatedLocales) {
|
|
100
|
+
for (const namespace of targetNamespaces) {
|
|
101
|
+
const snapshot = snapshots.get(JSON.stringify([locale, namespace]))
|
|
102
|
+
if (!snapshot) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Internal error: no staged snapshot for ${locale}/${namespace}.`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
await writeLocalesFile(config.savePath, locale, namespace, snapshot)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
key,
|
|
113
|
+
value,
|
|
114
|
+
sourceLocale,
|
|
115
|
+
targetNamespaces,
|
|
116
|
+
updatedLocales,
|
|
117
|
+
pendingLocales,
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/lib/utils.ts
CHANGED
|
@@ -7,6 +7,15 @@ import { minimatch } from "minimatch"
|
|
|
7
7
|
import type OpenAI from "openai"
|
|
8
8
|
import prompts from "prompts"
|
|
9
9
|
import { languages } from "./languges.js"
|
|
10
|
+
import {
|
|
11
|
+
resolveConfiguredLocale,
|
|
12
|
+
resolveLocaleTemplatePath,
|
|
13
|
+
} from "./locale-storage.js"
|
|
14
|
+
import {
|
|
15
|
+
parseTranslationResponse,
|
|
16
|
+
TranslationProviderUnavailableError,
|
|
17
|
+
TranslationResponseError,
|
|
18
|
+
} from "./translation-response.js"
|
|
10
19
|
import type { Configuration, GlobPatternConfig } from "./types.js"
|
|
11
20
|
|
|
12
21
|
export const loadConfig = async ({
|
|
@@ -17,8 +26,7 @@ export const loadConfig = async ({
|
|
|
17
26
|
const filePath = path.join(process.cwd(), configPath)
|
|
18
27
|
|
|
19
28
|
if (!fs.existsSync(filePath)) {
|
|
20
|
-
|
|
21
|
-
process.exit(1)
|
|
29
|
+
throw new Error(`Config file does not exist: ${filePath}`)
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
try {
|
|
@@ -33,32 +41,10 @@ export const loadConfig = async ({
|
|
|
33
41
|
errorMessage.includes("MODULE_NOT_FOUND") ||
|
|
34
42
|
errorMessage.includes("Cannot find module")
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
console.error(
|
|
41
|
-
" 1. Your config file imports dependencies that aren't installed",
|
|
42
|
-
)
|
|
43
|
-
console.error(
|
|
44
|
-
" 2. Dependencies are installed but have missing peer dependencies",
|
|
45
|
-
)
|
|
46
|
-
console.error(
|
|
47
|
-
" 3. You're using a custom storage solution (e.g., AWS S3) without proper dependencies",
|
|
48
|
-
)
|
|
49
|
-
console.error("\n To fix:")
|
|
50
|
-
console.error(
|
|
51
|
-
" - Check if your config file imports any external packages",
|
|
52
|
-
)
|
|
53
|
-
console.error(
|
|
54
|
-
" - Ensure all dependencies are properly installed: pnpm install",
|
|
55
|
-
)
|
|
56
|
-
console.error(
|
|
57
|
-
" - If using AWS SDK, ensure all @smithy/* peer dependencies are installed",
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
process.exit(1)
|
|
44
|
+
const hint = isModuleNotFound
|
|
45
|
+
? " Check the config imports and installed dependencies."
|
|
46
|
+
: ""
|
|
47
|
+
throw new Error(`Error while loading config: ${errorMessage}.${hint}`)
|
|
62
48
|
}
|
|
63
49
|
}
|
|
64
50
|
|
|
@@ -109,6 +95,8 @@ export const translateKey = async ({
|
|
|
109
95
|
}) => {
|
|
110
96
|
// Split object into chunks of 100 keys
|
|
111
97
|
const entries = Object.entries(object)
|
|
98
|
+
if (entries.length === 0) return {}
|
|
99
|
+
if (!openai) throw new TranslationProviderUnavailableError()
|
|
112
100
|
const chunks: Array<[string, string][]> = []
|
|
113
101
|
|
|
114
102
|
for (let i = 0; i < entries.length; i += 100) {
|
|
@@ -129,34 +117,57 @@ export const translateKey = async ({
|
|
|
129
117
|
|
|
130
118
|
for (const chunk of chunks) {
|
|
131
119
|
const chunkObject = Object.fromEntries(chunk)
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
120
|
+
const requestedKeys = chunk.map(([key]) => key)
|
|
121
|
+
let translatedChunk: Record<string, string> | undefined
|
|
122
|
+
|
|
123
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
124
|
+
const correctivePrompt =
|
|
125
|
+
attempt === 1
|
|
126
|
+
? ` The previous response was invalid. Return exactly these keys with string values and no additional keys: ${JSON.stringify(requestedKeys)}.`
|
|
127
|
+
: ""
|
|
128
|
+
const completion = await openai.chat.completions.create({
|
|
129
|
+
model,
|
|
130
|
+
messages: [
|
|
131
|
+
{
|
|
132
|
+
content: `You are a bot that translates the values of a locales JSON. ${
|
|
133
|
+
context
|
|
134
|
+
? `The user provided some additional context or guidelines about what to fill in the blanks: "${context}". `
|
|
135
|
+
: ""
|
|
136
|
+
}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"}.${correctivePrompt}`,
|
|
137
|
+
role: "system",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
content: JSON.stringify({
|
|
141
|
+
inputLanguage: input,
|
|
142
|
+
outputLanguage: output,
|
|
143
|
+
data: chunkObject,
|
|
144
|
+
}),
|
|
145
|
+
role: "user",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
response_format: {
|
|
149
|
+
type: "json_object",
|
|
150
150
|
},
|
|
151
|
-
|
|
152
|
-
response_format: {
|
|
153
|
-
type: "json_object",
|
|
154
|
-
},
|
|
155
|
-
})
|
|
151
|
+
})
|
|
156
152
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
153
|
+
try {
|
|
154
|
+
translatedChunk = parseTranslationResponse(
|
|
155
|
+
completion.choices[0]?.message.content ?? null,
|
|
156
|
+
requestedKeys,
|
|
157
|
+
)
|
|
158
|
+
break
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (!(error instanceof TranslationResponseError) || attempt === 1) {
|
|
161
|
+
throw error
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!translatedChunk) {
|
|
167
|
+
throw new TranslationResponseError(
|
|
168
|
+
"Translation provider did not return a valid response.",
|
|
169
|
+
)
|
|
170
|
+
}
|
|
160
171
|
|
|
161
172
|
// Merge translated chunk with result
|
|
162
173
|
result = { ...result, ...translatedChunk }
|
|
@@ -185,9 +196,7 @@ export const loadLocalesFile = async (
|
|
|
185
196
|
const silent = options?.silent ?? false
|
|
186
197
|
|
|
187
198
|
if (typeof loadPath === "string") {
|
|
188
|
-
const resolvedPath = loadPath
|
|
189
|
-
.replace("{{lng}}", locale)
|
|
190
|
-
.replace("{{ns}}", namespace)
|
|
199
|
+
const resolvedPath = resolveLocaleTemplatePath(loadPath, locale, namespace)
|
|
191
200
|
|
|
192
201
|
// Check if file exists, return empty object if it doesn't
|
|
193
202
|
if (!fs.existsSync(resolvedPath)) {
|
|
@@ -227,9 +236,11 @@ export const writeLocalesFile = async (
|
|
|
227
236
|
data: Record<string, string>,
|
|
228
237
|
) => {
|
|
229
238
|
if (typeof savePath === "string") {
|
|
230
|
-
const resolvedSavePath =
|
|
231
|
-
|
|
232
|
-
|
|
239
|
+
const resolvedSavePath = resolveLocaleTemplatePath(
|
|
240
|
+
savePath,
|
|
241
|
+
locale,
|
|
242
|
+
namespace,
|
|
243
|
+
)
|
|
233
244
|
|
|
234
245
|
// Ensure directory exists
|
|
235
246
|
const dir = path.dirname(resolvedSavePath)
|
|
@@ -876,6 +887,12 @@ export const addTranslationKeys = async ({
|
|
|
876
887
|
}
|
|
877
888
|
}
|
|
878
889
|
|
|
890
|
+
const validatedKeys = keys.map(({ key, value, language }) => ({
|
|
891
|
+
key,
|
|
892
|
+
value,
|
|
893
|
+
language: resolveConfiguredLocale(config, language),
|
|
894
|
+
}))
|
|
895
|
+
|
|
879
896
|
const log = console.log
|
|
880
897
|
const namespaceResolutionDebug =
|
|
881
898
|
process.env.DEBUG_NAMESPACE_RESOLUTION === "1" ||
|
|
@@ -923,7 +940,7 @@ export const addTranslationKeys = async ({
|
|
|
923
940
|
}),
|
|
924
941
|
)
|
|
925
942
|
|
|
926
|
-
const preparedKeys =
|
|
943
|
+
const preparedKeys = validatedKeys.map(({ key, value, language }) => {
|
|
927
944
|
const splitKey = key.split(":")
|
|
928
945
|
const hasNamespacedKey = splitKey.length > 1 && namespaces.includes(splitKey[0])
|
|
929
946
|
const normalizedKey = hasNamespacedKey ? splitKey.slice(1).join(":") : key
|
|
@@ -1029,6 +1046,7 @@ export const addTranslationKeys = async ({
|
|
|
1029
1046
|
const fileIOStartTime = performance.now()
|
|
1030
1047
|
const localeFiles = new Map<string, Record<string, string>>()
|
|
1031
1048
|
const originalKeyCounts = new Map<string, number>()
|
|
1049
|
+
const changedFiles = new Set<string>()
|
|
1032
1050
|
const loadErrors: Array<{ fileKey: string; error: string }> = []
|
|
1033
1051
|
|
|
1034
1052
|
// Load files for all locales × all affected namespaces in parallel (read-only)
|
|
@@ -1076,6 +1094,7 @@ export const addTranslationKeys = async ({
|
|
|
1076
1094
|
}
|
|
1077
1095
|
|
|
1078
1096
|
localeFiles.set(fileKey, existingKeys)
|
|
1097
|
+
changedFiles.add(fileKey)
|
|
1079
1098
|
}
|
|
1080
1099
|
|
|
1081
1100
|
// Step 7: Batch translate if OpenAI is configured
|
|
@@ -1119,18 +1138,12 @@ export const addTranslationKeys = async ({
|
|
|
1119
1138
|
object: keysToTranslate,
|
|
1120
1139
|
openai,
|
|
1121
1140
|
model,
|
|
1122
|
-
})
|
|
1123
|
-
.
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
})
|
|
1129
|
-
.catch((error) => {
|
|
1130
|
-
log(
|
|
1131
|
-
`⚠️ Failed to translate ${keyValues.length} key(s) from ${inputLanguage} to ${targetLocale}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1132
|
-
)
|
|
1133
|
-
}),
|
|
1141
|
+
}).then((translated) => {
|
|
1142
|
+
translationCache.set(
|
|
1143
|
+
`${inputLanguage}:${targetLocale}`,
|
|
1144
|
+
translated,
|
|
1145
|
+
)
|
|
1146
|
+
}),
|
|
1134
1147
|
)
|
|
1135
1148
|
}
|
|
1136
1149
|
}
|
|
@@ -1148,7 +1161,7 @@ export const addTranslationKeys = async ({
|
|
|
1148
1161
|
if (!translated) continue
|
|
1149
1162
|
|
|
1150
1163
|
for (const { key, namespaces } of keyValues) {
|
|
1151
|
-
if (translated
|
|
1164
|
+
if (Object.hasOwn(translated, key)) {
|
|
1152
1165
|
for (const namespace of namespaces) {
|
|
1153
1166
|
const fileKey = `${targetLocale}:${namespace}`
|
|
1154
1167
|
// File MUST already be loaded from step 5 - if not, something went wrong
|
|
@@ -1160,6 +1173,7 @@ export const addTranslationKeys = async ({
|
|
|
1160
1173
|
}
|
|
1161
1174
|
// Merge translated key with existing keys (don't overwrite the whole file)
|
|
1162
1175
|
localeFiles.get(fileKey)![key] = translated[key]
|
|
1176
|
+
changedFiles.add(fileKey)
|
|
1163
1177
|
}
|
|
1164
1178
|
}
|
|
1165
1179
|
}
|
|
@@ -1191,6 +1205,7 @@ export const addTranslationKeys = async ({
|
|
|
1191
1205
|
|
|
1192
1206
|
// All validations passed, now write files sequentially to avoid race conditions
|
|
1193
1207
|
for (const [fileKey, keys] of localeFiles) {
|
|
1208
|
+
if (!changedFiles.has(fileKey)) continue
|
|
1194
1209
|
const [locale, namespace] = fileKey.split(":")
|
|
1195
1210
|
await writeLocalesFile(savePath, locale, namespace, keys)
|
|
1196
1211
|
}
|
|
@@ -1212,7 +1227,7 @@ export const addTranslationKeys = async ({
|
|
|
1212
1227
|
config.locales.forEach((locale) => {
|
|
1213
1228
|
if (locale !== language) {
|
|
1214
1229
|
const translated = translationCache.get(`${language}:${locale}`)
|
|
1215
|
-
if (translated
|
|
1230
|
+
if (translated && Object.hasOwn(translated, key)) {
|
|
1216
1231
|
savedLocales.add(locale)
|
|
1217
1232
|
}
|
|
1218
1233
|
}
|
|
@@ -1222,6 +1237,7 @@ export const addTranslationKeys = async ({
|
|
|
1222
1237
|
return {
|
|
1223
1238
|
key,
|
|
1224
1239
|
value,
|
|
1240
|
+
providedLanguage: language,
|
|
1225
1241
|
namespace: resolvedNamespaces.join(", "),
|
|
1226
1242
|
locale: Array.from(savedLocales).sort().join(", "),
|
|
1227
1243
|
}
|
|
@@ -1241,7 +1257,7 @@ export const addTranslationKeys = async ({
|
|
|
1241
1257
|
export const addTranslationKey = async ({
|
|
1242
1258
|
key,
|
|
1243
1259
|
value,
|
|
1244
|
-
language
|
|
1260
|
+
language,
|
|
1245
1261
|
config,
|
|
1246
1262
|
}: {
|
|
1247
1263
|
key: string
|