@scoutello/i18n-magic 0.65.0 โ 0.67.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 +16 -10
- 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 +21 -28
- 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/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/mcp-write-schemas.d.ts +56 -0
- package/dist/lib/mcp-write-schemas.d.ts.map +1 -0
- package/dist/lib/mcp-write-schemas.js +36 -0
- package/dist/lib/mcp-write-schemas.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 +30 -117
- 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 +41 -63
- package/src/index.ts +6 -1
- package/src/lib/locale-storage.ts +76 -0
- package/src/lib/mcp-write-schemas.ts +46 -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 +38 -167
package/src/commands/scan.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { Configuration } from "../lib/types.js"
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
getMissingKeys,
|
|
3
|
+
getKeysWithNamespaces,
|
|
4
|
+
getPureKey,
|
|
6
5
|
getTextInput,
|
|
7
6
|
loadLocalesFile,
|
|
8
7
|
translateKey,
|
|
@@ -10,11 +9,15 @@ import {
|
|
|
10
9
|
} from "../lib/utils.js"
|
|
11
10
|
import { findUnusedKeys, removeUnusedKeys } from "./clean.js"
|
|
12
11
|
|
|
12
|
+
const fileKey = (locale: string, namespace: string) =>
|
|
13
|
+
JSON.stringify([locale, namespace])
|
|
14
|
+
|
|
13
15
|
export const translateMissing = async (config: Configuration) => {
|
|
14
16
|
const {
|
|
15
17
|
loadPath,
|
|
16
18
|
savePath,
|
|
17
19
|
defaultLocale,
|
|
20
|
+
defaultNamespace,
|
|
18
21
|
namespaces,
|
|
19
22
|
locales,
|
|
20
23
|
context,
|
|
@@ -22,138 +25,157 @@ export const translateMissing = async (config: Configuration) => {
|
|
|
22
25
|
disableTranslationDuringScan,
|
|
23
26
|
autoClear,
|
|
24
27
|
} = config
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
const activeLocales = disableTranslationDuringScan
|
|
29
|
+
? [defaultLocale]
|
|
30
|
+
: locales
|
|
31
|
+
const keysWithNamespaces = await getKeysWithNamespaces({
|
|
32
|
+
globPatterns: config.globPatterns,
|
|
33
|
+
defaultNamespace,
|
|
34
|
+
})
|
|
35
|
+
const expectedByNamespace = Object.fromEntries(
|
|
36
|
+
namespaces.map((namespace) => [namespace, new Set<string>()]),
|
|
37
|
+
) as Record<string, Set<string>>
|
|
38
|
+
|
|
39
|
+
for (const { key, namespaces: keyNamespaces } of keysWithNamespaces) {
|
|
40
|
+
for (const namespace of keyNamespaces) {
|
|
41
|
+
if (!expectedByNamespace[namespace]) continue
|
|
42
|
+
const pureKey = getPureKey(
|
|
43
|
+
key,
|
|
44
|
+
namespace,
|
|
45
|
+
namespace === defaultNamespace,
|
|
46
|
+
)
|
|
47
|
+
const expectedKey = pureKey || (!key.includes(":") ? key : null)
|
|
48
|
+
if (expectedKey !== null) expectedByNamespace[namespace].add(expectedKey)
|
|
35
49
|
}
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const newKeysWithDefaultLocale = []
|
|
53
|
-
|
|
54
|
-
// Check for existing translations in parallel
|
|
55
|
-
const keysList = newKeys.map((k) => k.key)
|
|
56
|
-
const existingTranslationResults = await findExistingTranslations(
|
|
57
|
-
keysList,
|
|
58
|
-
namespaces,
|
|
59
|
-
defaultLocale,
|
|
60
|
-
loadPath,
|
|
52
|
+
const stagedFiles = new Map<string, Record<string, string>>()
|
|
53
|
+
await Promise.all(
|
|
54
|
+
activeLocales.flatMap((locale) =>
|
|
55
|
+
namespaces.map(async (namespace) => {
|
|
56
|
+
const translations = await loadLocalesFile(
|
|
57
|
+
loadPath,
|
|
58
|
+
locale,
|
|
59
|
+
namespace,
|
|
60
|
+
{ silent: true },
|
|
61
|
+
)
|
|
62
|
+
stagedFiles.set(fileKey(locale, namespace), { ...translations })
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
61
65
|
)
|
|
62
66
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
} else {
|
|
73
|
-
answer = await getTextInput(newKey.key, newKey.namespaces)
|
|
67
|
+
const changedFiles = new Set<string>()
|
|
68
|
+
const missingDefaultKeys = new Map<string, Set<string>>()
|
|
69
|
+
for (const namespace of namespaces) {
|
|
70
|
+
const translations = stagedFiles.get(fileKey(defaultLocale, namespace))!
|
|
71
|
+
for (const key of expectedByNamespace[namespace]) {
|
|
72
|
+
if (!Object.hasOwn(translations, key)) {
|
|
73
|
+
missingDefaultKeys.get(key)?.add(namespace) ??
|
|
74
|
+
missingDefaultKeys.set(key, new Set([namespace]))
|
|
75
|
+
}
|
|
74
76
|
}
|
|
75
|
-
|
|
76
|
-
newKeysWithDefaultLocale.push({
|
|
77
|
-
key: newKey.key,
|
|
78
|
-
namespace: newKey.namespace,
|
|
79
|
-
namespaces: newKey.namespaces,
|
|
80
|
-
value: answer,
|
|
81
|
-
})
|
|
82
77
|
}
|
|
83
78
|
|
|
84
|
-
|
|
85
|
-
if (reusedKeys.length > 0) {
|
|
79
|
+
if (missingDefaultKeys.size > 0) {
|
|
86
80
|
console.log(
|
|
87
|
-
|
|
81
|
+
`${missingDefaultKeys.size} keys are missing. Please provide the values for the following keys in ${defaultLocale}:`,
|
|
88
82
|
)
|
|
89
83
|
}
|
|
90
84
|
|
|
91
|
-
const
|
|
92
|
-
|
|
85
|
+
for (const [key, keyNamespaces] of missingDefaultKeys) {
|
|
86
|
+
let value: string | undefined
|
|
87
|
+
for (const namespace of namespaces) {
|
|
88
|
+
const translations = stagedFiles.get(fileKey(defaultLocale, namespace))!
|
|
89
|
+
if (Object.hasOwn(translations, key)) {
|
|
90
|
+
value = translations[key]
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
value ??= await getTextInput(key, Array.from(keyNamespaces))
|
|
96
|
+
for (const namespace of keyNamespaces) {
|
|
97
|
+
stagedFiles.get(fileKey(defaultLocale, namespace))![key] = value
|
|
98
|
+
changedFiles.add(fileKey(defaultLocale, namespace))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
96
101
|
|
|
97
|
-
|
|
102
|
+
await Promise.all(
|
|
103
|
+
activeLocales
|
|
104
|
+
.filter((locale) => locale !== defaultLocale)
|
|
105
|
+
.map(async (locale) => {
|
|
106
|
+
const missingByKey = new Map<string, Set<string>>()
|
|
107
|
+
for (const namespace of namespaces) {
|
|
108
|
+
const translations = stagedFiles.get(fileKey(locale, namespace))!
|
|
109
|
+
for (const key of expectedByNamespace[namespace]) {
|
|
110
|
+
if (!Object.hasOwn(translations, key)) {
|
|
111
|
+
missingByKey.get(key)?.add(namespace) ??
|
|
112
|
+
missingByKey.set(key, new Set([namespace]))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
98
116
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
117
|
+
const reused: Record<string, string> = {}
|
|
118
|
+
const keysToTranslate: Record<string, string> = {}
|
|
119
|
+
for (const key of missingByKey.keys()) {
|
|
120
|
+
let existingValue: string | undefined
|
|
121
|
+
for (const namespace of namespaces) {
|
|
122
|
+
const translations = stagedFiles.get(fileKey(locale, namespace))!
|
|
123
|
+
if (Object.hasOwn(translations, key)) {
|
|
124
|
+
existingValue = translations[key]
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (existingValue !== undefined) {
|
|
130
|
+
reused[key] = existingValue
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sourceNamespace = Array.from(missingByKey.get(key)!)[0]
|
|
135
|
+
const sourceTranslations = stagedFiles.get(
|
|
136
|
+
fileKey(defaultLocale, sourceNamespace),
|
|
137
|
+
)!
|
|
138
|
+
keysToTranslate[key] = sourceTranslations[key]
|
|
139
|
+
}
|
|
103
140
|
|
|
104
|
-
|
|
105
|
-
if (nonDefaultLocales.length > 0) {
|
|
106
|
-
await Promise.all(
|
|
107
|
-
nonDefaultLocales.map(async (locale) => {
|
|
108
|
-
const translatedValues = await translateKey({
|
|
141
|
+
const translated = await translateKey({
|
|
109
142
|
inputLanguage: defaultLocale,
|
|
110
143
|
outputLanguage: locale,
|
|
111
144
|
context,
|
|
112
|
-
object:
|
|
145
|
+
object: keysToTranslate,
|
|
113
146
|
openai,
|
|
114
147
|
model: config.model,
|
|
115
148
|
})
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
namespace: string
|
|
125
|
-
keyCount: number
|
|
126
|
-
}> = []
|
|
127
|
-
await Promise.all(
|
|
128
|
-
allLocales.flatMap((locale) =>
|
|
129
|
-
namespaces.map(async (namespace) => {
|
|
130
|
-
const existingKeys = await loadLocalesFile(loadPath, locale, namespace)
|
|
131
|
-
|
|
132
|
-
const relevantKeys = newKeysWithDefaultLocale.filter((key) =>
|
|
133
|
-
key.namespaces?.includes(namespace),
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
if (relevantKeys.length === 0) {
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const translatedValues = translationCache[locale]
|
|
141
|
-
for (const key of relevantKeys) {
|
|
142
|
-
existingKeys[key.key] = translatedValues[key.key]
|
|
149
|
+
const resolvedValues = { ...reused, ...translated }
|
|
150
|
+
|
|
151
|
+
for (const [key, keyNamespaces] of missingByKey) {
|
|
152
|
+
for (const namespace of keyNamespaces) {
|
|
153
|
+
stagedFiles.get(fileKey(locale, namespace))![key] =
|
|
154
|
+
resolvedValues[key]
|
|
155
|
+
changedFiles.add(fileKey(locale, namespace))
|
|
156
|
+
}
|
|
143
157
|
}
|
|
144
|
-
|
|
145
|
-
await writeLocalesFile(savePath, locale, namespace, existingKeys)
|
|
146
|
-
writeResults.push({ locale, namespace, keyCount: relevantKeys.length })
|
|
147
158
|
}),
|
|
148
|
-
),
|
|
149
159
|
)
|
|
150
160
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
for (const locale of activeLocales) {
|
|
162
|
+
for (const namespace of namespaces) {
|
|
163
|
+
const key = fileKey(locale, namespace)
|
|
164
|
+
if (!changedFiles.has(key)) continue
|
|
165
|
+
await writeLocalesFile(savePath, locale, namespace, stagedFiles.get(key)!)
|
|
166
|
+
console.log(` ๐ Updated ${locale}/${namespace}.json`)
|
|
167
|
+
}
|
|
154
168
|
}
|
|
155
169
|
|
|
156
|
-
|
|
170
|
+
if (autoClear) {
|
|
171
|
+
console.log("๐งน Checking for unused translations after scanning...")
|
|
172
|
+
const report = await findUnusedKeys(config)
|
|
173
|
+
if (report.unusedCount > 0) await removeUnusedKeys(config)
|
|
174
|
+
}
|
|
157
175
|
|
|
158
|
-
|
|
176
|
+
if (changedFiles.size === 0) {
|
|
177
|
+
console.log("No missing translations found.")
|
|
178
|
+
} else {
|
|
179
|
+
console.log(`Successfully updated ${changedFiles.size} locale file(s).`)
|
|
180
|
+
}
|
|
159
181
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import fs from "node:fs"
|
|
2
1
|
import chalk from "chalk"
|
|
3
2
|
import cliProgress from "cli-progress"
|
|
4
3
|
import { languages } from "../lib/languges.js"
|
|
5
4
|
import type { Configuration } from "../lib/types.js"
|
|
6
5
|
import {
|
|
7
|
-
findExistingTranslations,
|
|
8
6
|
loadLocalesFile,
|
|
9
7
|
TranslationError,
|
|
10
8
|
translateKey,
|
|
@@ -70,6 +68,11 @@ export const syncLocales = async (config: Configuration) => {
|
|
|
70
68
|
}
|
|
71
69
|
}
|
|
72
70
|
|
|
71
|
+
const stagedFiles = new Map<
|
|
72
|
+
string,
|
|
73
|
+
{ locale: string; namespace: string; translations: Record<string, string> }
|
|
74
|
+
>()
|
|
75
|
+
|
|
73
76
|
const progressBars = new cliProgress.MultiBar(
|
|
74
77
|
{
|
|
75
78
|
format: ` {label} {bar} {percentage}% | {value}/{total} | {duration_formatted} | {status}`,
|
|
@@ -99,28 +102,6 @@ export const syncLocales = async (config: Configuration) => {
|
|
|
99
102
|
const suppressedLog = () => {}
|
|
100
103
|
|
|
101
104
|
try {
|
|
102
|
-
// Helper to ensure a locale/namespace file exists before we add keys
|
|
103
|
-
const ensureLocaleNamespaceFile = async (
|
|
104
|
-
locale: string,
|
|
105
|
-
namespace: string,
|
|
106
|
-
) => {
|
|
107
|
-
if (typeof savePath === "string") {
|
|
108
|
-
const filePath = savePath
|
|
109
|
-
.replace("{{lng}}", locale)
|
|
110
|
-
.replace("{{ns}}", namespace)
|
|
111
|
-
if (!fs.existsSync(filePath)) {
|
|
112
|
-
await writeLocalesFile(savePath, locale, namespace, {})
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Ensure all default-locale namespace files exist first
|
|
118
|
-
await Promise.all(
|
|
119
|
-
namespaces.map((namespace) =>
|
|
120
|
-
ensureLocaleNamespaceFile(defaultLocale, namespace),
|
|
121
|
-
),
|
|
122
|
-
)
|
|
123
|
-
|
|
124
105
|
// Process locales in parallel batches
|
|
125
106
|
let completedCount = 0
|
|
126
107
|
const currentlyProcessing: Set<string> = new Set()
|
|
@@ -187,13 +168,6 @@ export const syncLocales = async (config: Configuration) => {
|
|
|
187
168
|
updateProgressDisplay()
|
|
188
169
|
|
|
189
170
|
try {
|
|
190
|
-
// Ensure all namespace files for this locale exist
|
|
191
|
-
await Promise.all(
|
|
192
|
-
namespaces.map((namespace) =>
|
|
193
|
-
ensureLocaleNamespaceFile(locale, namespace),
|
|
194
|
-
),
|
|
195
|
-
)
|
|
196
|
-
|
|
197
171
|
// Collect all missing keys for this locale across all namespaces
|
|
198
172
|
const allMissingKeys: Record<
|
|
199
173
|
string,
|
|
@@ -232,7 +206,7 @@ export const syncLocales = async (config: Configuration) => {
|
|
|
232
206
|
namespaceKeys[namespace] = localeKeys
|
|
233
207
|
|
|
234
208
|
for (const [key, value] of Object.entries(defaultLocaleKeys)) {
|
|
235
|
-
if (!localeKeys
|
|
209
|
+
if (!Object.hasOwn(localeKeys, key)) {
|
|
236
210
|
if (allMissingKeys[key]) {
|
|
237
211
|
allMissingKeys[key].namespaces.push(namespace)
|
|
238
212
|
} else {
|
|
@@ -259,18 +233,12 @@ export const syncLocales = async (config: Configuration) => {
|
|
|
259
233
|
const keysToTranslate: Record<string, string> = {}
|
|
260
234
|
const existingTranslations: Record<string, string> = {}
|
|
261
235
|
|
|
262
|
-
const existingTranslationResults = await findExistingTranslations(
|
|
263
|
-
missingKeysList,
|
|
264
|
-
namespaces,
|
|
265
|
-
locale,
|
|
266
|
-
loadPath,
|
|
267
|
-
{ silent: true },
|
|
268
|
-
)
|
|
269
|
-
|
|
270
236
|
for (const key of missingKeysList) {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
237
|
+
const existingNamespace = namespaces.find((namespace) =>
|
|
238
|
+
Object.hasOwn(namespaceKeys[namespace], key),
|
|
239
|
+
)
|
|
240
|
+
if (existingNamespace !== undefined) {
|
|
241
|
+
existingTranslations[key] = namespaceKeys[existingNamespace][key]
|
|
274
242
|
localeResults[locale].reused++
|
|
275
243
|
} else {
|
|
276
244
|
keysToTranslate[key] = allMissingKeys[key].value
|
|
@@ -319,24 +287,25 @@ export const syncLocales = async (config: Configuration) => {
|
|
|
319
287
|
// Combine and save
|
|
320
288
|
const allTranslations = { ...existingTranslations, ...translatedValues }
|
|
321
289
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const updatedKeys = { ...namespaceKeys[namespace] }
|
|
326
|
-
|
|
327
|
-
for (const key of missingKeysList) {
|
|
328
|
-
if (allMissingKeys[key].namespaces.includes(namespace)) {
|
|
329
|
-
const translation = allTranslations[key] || ""
|
|
330
|
-
updatedKeys[key] = translation
|
|
331
|
-
hasChanges = true
|
|
332
|
-
}
|
|
333
|
-
}
|
|
290
|
+
for (const namespace of namespaces) {
|
|
291
|
+
let hasChanges = false
|
|
292
|
+
const updatedKeys = { ...namespaceKeys[namespace] }
|
|
334
293
|
|
|
335
|
-
|
|
336
|
-
|
|
294
|
+
for (const key of missingKeysList) {
|
|
295
|
+
if (allMissingKeys[key].namespaces.includes(namespace)) {
|
|
296
|
+
updatedKeys[key] = allTranslations[key]
|
|
297
|
+
hasChanges = true
|
|
337
298
|
}
|
|
338
|
-
}
|
|
339
|
-
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (hasChanges) {
|
|
302
|
+
stagedFiles.set(JSON.stringify([locale, namespace]), {
|
|
303
|
+
locale,
|
|
304
|
+
namespace,
|
|
305
|
+
translations: updatedKeys,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
}
|
|
340
309
|
|
|
341
310
|
localeResults[locale].status = "done"
|
|
342
311
|
currentlyProcessing.delete(locale)
|
|
@@ -366,6 +335,19 @@ export const syncLocales = async (config: Configuration) => {
|
|
|
366
335
|
|
|
367
336
|
progressBars.stop()
|
|
368
337
|
|
|
338
|
+
const errorLocales = localesToProcess.filter(
|
|
339
|
+
(locale) => localeResults[locale].status === "error",
|
|
340
|
+
)
|
|
341
|
+
if (errorLocales.length > 0) {
|
|
342
|
+
throw new TranslationError(
|
|
343
|
+
`Sync failed for ${errorLocales.length} locale(s); no translations were written.`,
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
for (const { locale, namespace, translations } of stagedFiles.values()) {
|
|
348
|
+
await writeLocalesFile(savePath, locale, namespace, translations)
|
|
349
|
+
}
|
|
350
|
+
|
|
369
351
|
// Print summary
|
|
370
352
|
console.log("")
|
|
371
353
|
console.log(chalk.green("โจ Sync complete!\n"))
|
|
@@ -389,10 +371,6 @@ export const syncLocales = async (config: Configuration) => {
|
|
|
389
371
|
const doneLocales = localesToProcess.filter(
|
|
390
372
|
(l) => localeResults[l].status === "done",
|
|
391
373
|
)
|
|
392
|
-
const errorLocales = localesToProcess.filter(
|
|
393
|
-
(l) => localeResults[l].status === "error",
|
|
394
|
-
)
|
|
395
|
-
|
|
396
374
|
// Show successful languages in a compact grid
|
|
397
375
|
if (doneLocales.length > 0) {
|
|
398
376
|
const columns = 3
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// Export command functions for programmatic usage
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
checkMissing,
|
|
4
|
+
collectMissingTranslations,
|
|
5
|
+
MissingTranslationsError,
|
|
6
|
+
} from "./commands/check-missing.js"
|
|
7
|
+
export type { MissingTranslationsReport } from "./commands/check-missing.js"
|
|
3
8
|
export { removeUnusedKeys } from "./commands/clean.js"
|
|
4
9
|
|
|
5
10
|
export { replaceTranslation } from "./commands/replace.js"
|
|
@@ -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,46 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
const ExplicitSourceLanguageSchema = z
|
|
4
|
+
.string()
|
|
5
|
+
.min(1)
|
|
6
|
+
.describe(
|
|
7
|
+
"Required configured language code of the provided value (for example, en or de).",
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
export const MCP_WRITE_REQUIRED_FIELDS = [
|
|
11
|
+
"key",
|
|
12
|
+
"value",
|
|
13
|
+
"language",
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
export const AddTranslationKeySchema = z.object({
|
|
17
|
+
key: z
|
|
18
|
+
.string()
|
|
19
|
+
.describe('The translation key to add (e.g., "welcomeMessage").'),
|
|
20
|
+
value: z.string().describe("The text value for this translation key"),
|
|
21
|
+
language: ExplicitSourceLanguageSchema,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export const AddTranslationKeysSchema = z.object({
|
|
25
|
+
keys: z
|
|
26
|
+
.array(
|
|
27
|
+
z.object({
|
|
28
|
+
key: z
|
|
29
|
+
.string()
|
|
30
|
+
.describe('The translation key to add (e.g., "welcomeMessage").'),
|
|
31
|
+
value: z.string().describe("The text value for this translation key"),
|
|
32
|
+
language: ExplicitSourceLanguageSchema,
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
.describe(
|
|
36
|
+
"Array of translation keys to add in batch. Use this for adding multiple keys at once for better performance.",
|
|
37
|
+
),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export const UpdateTranslationKeySchema = z.object({
|
|
41
|
+
key: z
|
|
42
|
+
.string()
|
|
43
|
+
.describe('The translation key to update (e.g., "welcomeMessage").'),
|
|
44
|
+
value: z.string().describe("The new text value for this translation key"),
|
|
45
|
+
language: ExplicitSourceLanguageSchema,
|
|
46
|
+
})
|
|
@@ -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