@scoutello/i18n-magic 0.53.0 → 0.55.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 +236 -266
- package/dist/lib/utils.d.ts +32 -4
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +183 -73
- package/dist/lib/utils.js.map +1 -1
- package/dist/mcp-server.js +174 -16
- package/dist/mcp-server.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/utils.ts +234 -89
- package/src/mcp-server.ts +198 -16
package/src/lib/utils.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Parser } from "i18next-scanner"
|
|
|
3
3
|
import { minimatch } from "minimatch"
|
|
4
4
|
import fs from "node:fs"
|
|
5
5
|
import path from "node:path"
|
|
6
|
+
import { performance } from "node:perf_hooks"
|
|
6
7
|
import type OpenAI from "openai"
|
|
7
8
|
import prompts from "prompts"
|
|
8
9
|
import { languages } from "./languges.js"
|
|
@@ -688,31 +689,53 @@ export class TranslationError extends Error {
|
|
|
688
689
|
}
|
|
689
690
|
|
|
690
691
|
/**
|
|
691
|
-
* Add a translation key with
|
|
692
|
-
* This function
|
|
693
|
-
*
|
|
692
|
+
* Add a translation key with a value in a specific language to the locale files.
|
|
693
|
+
* This function adds to the specified language locale, and will also translate
|
|
694
|
+
* and save to other locales if OpenAI is configured.
|
|
694
695
|
*/
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
696
|
+
/**
|
|
697
|
+
* Add multiple translation keys in batch. This is optimized for performance:
|
|
698
|
+
* - Single codebase scan for all keys
|
|
699
|
+
* - Batched file I/O operations
|
|
700
|
+
* - Batched translations per locale
|
|
701
|
+
*/
|
|
702
|
+
export const addTranslationKeys = async ({
|
|
703
|
+
keys,
|
|
698
704
|
config,
|
|
699
705
|
}: {
|
|
700
|
-
key: string
|
|
701
|
-
value: string
|
|
706
|
+
keys: Array<{ key: string; value: string; language?: string }>
|
|
702
707
|
config: Configuration
|
|
703
708
|
}) => {
|
|
709
|
+
const startTime = performance.now()
|
|
704
710
|
const { loadPath, savePath, defaultNamespace, namespaces, globPatterns, defaultLocale, openai, context, model } = config
|
|
705
711
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
712
|
+
if (keys.length === 0) {
|
|
713
|
+
return { results: [], performance: { totalTime: 0, scanTime: 0, translationTime: 0, fileIOTime: 0 } }
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const log = console.log
|
|
717
|
+
log(`🚀 Batch adding ${keys.length} translation key(s)...`)
|
|
718
|
+
|
|
719
|
+
// Step 1: Single codebase scan for all keys (most expensive operation)
|
|
720
|
+
const scanStartTime = performance.now()
|
|
721
|
+
let keysWithNamespaces: Array<{ key: string; namespaces: string[]; file: string }> = []
|
|
709
722
|
try {
|
|
710
|
-
|
|
711
|
-
const keysWithNamespaces = await getKeysWithNamespaces({
|
|
723
|
+
keysWithNamespaces = await getKeysWithNamespaces({
|
|
712
724
|
globPatterns,
|
|
713
725
|
defaultNamespace,
|
|
714
726
|
})
|
|
727
|
+
} catch (error) {
|
|
728
|
+
console.error(`Warning: Failed to scan codebase for key usage: ${error}`)
|
|
729
|
+
}
|
|
730
|
+
const scanTime = performance.now() - scanStartTime
|
|
731
|
+
log(`⏱️ Codebase scan completed in ${scanTime.toFixed(2)}ms`)
|
|
715
732
|
|
|
733
|
+
// Step 2: Determine namespaces for each key
|
|
734
|
+
const keyToNamespaces = new Map<string, Set<string>>()
|
|
735
|
+
|
|
736
|
+
for (const { key, language = "en" } of keys) {
|
|
737
|
+
const affectedNamespaces: string[] = []
|
|
738
|
+
|
|
716
739
|
// Find entries for this specific key
|
|
717
740
|
const keyEntries = keysWithNamespaces.filter(
|
|
718
741
|
(entry) => entry.key === key || entry.key === `${defaultNamespace}:${key}`
|
|
@@ -728,97 +751,219 @@ export const addTranslationKey = async ({
|
|
|
728
751
|
|
|
729
752
|
if (foundNamespaces.size > 0) {
|
|
730
753
|
affectedNamespaces.push(...Array.from(foundNamespaces))
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
)
|
|
754
|
+
} else {
|
|
755
|
+
// If the key is not found in the codebase, use the default namespace
|
|
756
|
+
affectedNamespaces.push(defaultNamespace)
|
|
734
757
|
}
|
|
735
|
-
} catch (error) {
|
|
736
|
-
// If scanning fails, continue with fallback logic
|
|
737
|
-
console.error(`Warning: Failed to scan codebase for key usage: ${error}`)
|
|
738
|
-
}
|
|
739
758
|
|
|
740
|
-
|
|
741
|
-
// If the key is not found in the codebase, use the default namespace
|
|
742
|
-
affectedNamespaces.push(defaultNamespace)
|
|
743
|
-
console.log(
|
|
744
|
-
`📝 Key "${key}" not found in codebase. Adding to default namespace "${defaultNamespace}".`
|
|
745
|
-
)
|
|
759
|
+
keyToNamespaces.set(key, new Set(affectedNamespaces))
|
|
746
760
|
}
|
|
747
761
|
|
|
748
|
-
//
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
try {
|
|
760
|
-
existingKeys = await loadLocalesFile(loadPath, locale, targetNamespace)
|
|
761
|
-
} catch (error) {
|
|
762
|
-
// If file doesn't exist, start with empty object
|
|
763
|
-
log(`📄 Creating new namespace file for ${locale}/${targetNamespace}`)
|
|
764
|
-
existingKeys = {}
|
|
762
|
+
// Step 3: Group keys by namespace and locale
|
|
763
|
+
const namespaceLocaleToKeys = new Map<string, Array<{ key: string; value: string; language: string }>>()
|
|
764
|
+
|
|
765
|
+
for (const { key, value, language = "en" } of keys) {
|
|
766
|
+
const namespaces = keyToNamespaces.get(key) || new Set([defaultNamespace])
|
|
767
|
+
for (const namespace of namespaces) {
|
|
768
|
+
const mapKey = `${namespace}:${language}`
|
|
769
|
+
if (!namespaceLocaleToKeys.has(mapKey)) {
|
|
770
|
+
namespaceLocaleToKeys.set(mapKey, [])
|
|
771
|
+
}
|
|
772
|
+
namespaceLocaleToKeys.get(mapKey)!.push({ key, value, language })
|
|
765
773
|
}
|
|
774
|
+
}
|
|
766
775
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
776
|
+
// Step 4: Batch load all locale files needed
|
|
777
|
+
const fileIOStartTime = performance.now()
|
|
778
|
+
const localeFiles = new Map<string, Record<string, string>>()
|
|
779
|
+
const loadPromises: Promise<void>[] = []
|
|
780
|
+
|
|
781
|
+
for (const [namespaceLocale, _] of namespaceLocaleToKeys) {
|
|
782
|
+
const [namespace, locale] = namespaceLocale.split(":")
|
|
783
|
+
const fileKey = `${locale}:${namespace}`
|
|
784
|
+
|
|
785
|
+
if (!localeFiles.has(fileKey)) {
|
|
786
|
+
loadPromises.push(
|
|
787
|
+
loadLocalesFile(loadPath, locale, namespace)
|
|
788
|
+
.then((keys) => {
|
|
789
|
+
localeFiles.set(fileKey, keys)
|
|
790
|
+
})
|
|
791
|
+
.catch(() => {
|
|
792
|
+
localeFiles.set(fileKey, {})
|
|
793
|
+
})
|
|
794
|
+
)
|
|
771
795
|
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
await Promise.all(loadPromises)
|
|
799
|
+
const fileIOTime = performance.now() - fileIOStartTime
|
|
800
|
+
log(`⏱️ File I/O (load) completed in ${fileIOTime.toFixed(2)}ms`)
|
|
801
|
+
|
|
802
|
+
// Step 5: Add keys to loaded files
|
|
803
|
+
for (const [namespaceLocale, keyValues] of namespaceLocaleToKeys) {
|
|
804
|
+
const [namespace, locale] = namespaceLocale.split(":")
|
|
805
|
+
const fileKey = `${locale}:${namespace}`
|
|
806
|
+
const existingKeys = localeFiles.get(fileKey) || {}
|
|
807
|
+
|
|
808
|
+
for (const { key, value } of keyValues) {
|
|
809
|
+
existingKeys[key] = value
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
localeFiles.set(fileKey, existingKeys)
|
|
813
|
+
}
|
|
772
814
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
815
|
+
// Step 6: Batch translate if OpenAI is configured
|
|
816
|
+
const translationStartTime = performance.now()
|
|
817
|
+
const translationCache = new Map<string, Record<string, string>>()
|
|
818
|
+
|
|
819
|
+
if (openai) {
|
|
820
|
+
// Group keys by input language
|
|
821
|
+
const keysByLanguage = new Map<string, Array<{ key: string; value: string; namespaces: Set<string> }>>()
|
|
822
|
+
|
|
823
|
+
for (const { key, value, language = "en" } of keys) {
|
|
824
|
+
if (!keysByLanguage.has(language)) {
|
|
825
|
+
keysByLanguage.set(language, [])
|
|
826
|
+
}
|
|
827
|
+
keysByLanguage.get(language)!.push({
|
|
828
|
+
key,
|
|
829
|
+
value,
|
|
830
|
+
namespaces: keyToNamespaces.get(key) || new Set([defaultNamespace]),
|
|
831
|
+
})
|
|
832
|
+
}
|
|
779
833
|
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
|
|
834
|
+
// Translate each language group to all other locales
|
|
835
|
+
const translationPromises: Promise<void>[] = []
|
|
836
|
+
|
|
837
|
+
for (const [inputLanguage, keyValues] of keysByLanguage) {
|
|
838
|
+
const otherLocales = config.locales.filter(l => l !== inputLanguage)
|
|
783
839
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
840
|
+
for (const targetLocale of otherLocales) {
|
|
841
|
+
const keysToTranslate = Object.fromEntries(
|
|
842
|
+
keyValues.map(({ key, value }) => [key, value])
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
translationPromises.push(
|
|
846
|
+
translateKey({
|
|
847
|
+
inputLanguage,
|
|
848
|
+
outputLanguage: targetLocale,
|
|
849
|
+
context: context || "",
|
|
850
|
+
object: keysToTranslate,
|
|
851
|
+
openai,
|
|
852
|
+
model,
|
|
853
|
+
})
|
|
854
|
+
.then((translated) => {
|
|
855
|
+
translationCache.set(`${inputLanguage}:${targetLocale}`, translated)
|
|
856
|
+
})
|
|
857
|
+
.catch((error) => {
|
|
858
|
+
log(`⚠️ Failed to translate ${keyValues.length} key(s) from ${inputLanguage} to ${targetLocale}: ${error instanceof Error ? error.message : "Unknown error"}`)
|
|
859
|
+
})
|
|
860
|
+
)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
await Promise.all(translationPromises)
|
|
865
|
+
|
|
866
|
+
// Add translated keys to locale files
|
|
867
|
+
for (const [inputLanguage, keyValues] of keysByLanguage) {
|
|
868
|
+
const otherLocales = config.locales.filter(l => l !== inputLanguage)
|
|
869
|
+
|
|
870
|
+
for (const targetLocale of otherLocales) {
|
|
871
|
+
const translated = translationCache.get(`${inputLanguage}:${targetLocale}`)
|
|
872
|
+
if (!translated) continue
|
|
873
|
+
|
|
874
|
+
for (const { key, namespaces } of keyValues) {
|
|
875
|
+
if (translated[key]) {
|
|
876
|
+
for (const namespace of namespaces) {
|
|
877
|
+
const fileKey = `${targetLocale}:${namespace}`
|
|
878
|
+
if (!localeFiles.has(fileKey)) {
|
|
879
|
+
localeFiles.set(fileKey, {})
|
|
880
|
+
}
|
|
881
|
+
localeFiles.get(fileKey)![key] = translated[key]
|
|
882
|
+
}
|
|
883
|
+
}
|
|
803
884
|
}
|
|
804
|
-
|
|
805
|
-
// Add the translated key
|
|
806
|
-
defaultLocaleKeys[key] = translatedValue[key]
|
|
807
|
-
|
|
808
|
-
// Save the updated keys
|
|
809
|
-
await writeLocalesFile(savePath, defaultLocale, targetNamespace, defaultLocaleKeys)
|
|
810
|
-
log(`✅ Successfully translated and saved key to ${defaultLocale}/${targetNamespace}`)
|
|
811
|
-
} catch (error) {
|
|
812
|
-
log(`⚠️ Failed to translate key to ${defaultLocale}: ${error instanceof Error ? error.message : "Unknown error"}`)
|
|
813
|
-
log(` You can run 'i18n-magic sync' to translate this key later`)
|
|
814
885
|
}
|
|
815
886
|
}
|
|
816
887
|
}
|
|
888
|
+
|
|
889
|
+
const translationTime = performance.now() - translationStartTime
|
|
890
|
+
if (openai && translationTime > 0) {
|
|
891
|
+
log(`⏱️ Translation completed in ${translationTime.toFixed(2)}ms`)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Step 7: Batch write all files
|
|
895
|
+
const writeStartTime = performance.now()
|
|
896
|
+
const writePromises: Promise<void>[] = []
|
|
897
|
+
|
|
898
|
+
for (const [fileKey, keys] of localeFiles) {
|
|
899
|
+
const [locale, namespace] = fileKey.split(":")
|
|
900
|
+
writePromises.push(
|
|
901
|
+
writeLocalesFile(savePath, locale, namespace, keys)
|
|
902
|
+
)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
await Promise.all(writePromises)
|
|
906
|
+
const writeTime = performance.now() - writeStartTime
|
|
907
|
+
log(`⏱️ File I/O (write) completed in ${writeTime.toFixed(2)}ms`)
|
|
908
|
+
|
|
909
|
+
const totalTime = performance.now() - startTime
|
|
910
|
+
log(`✅ Batch operation completed in ${totalTime.toFixed(2)}ms (${(totalTime / keys.length).toFixed(2)}ms per key)`)
|
|
911
|
+
|
|
912
|
+
// Build results
|
|
913
|
+
const results = keys.map(({ key, value, language = "en" }) => {
|
|
914
|
+
const namespaces = Array.from(keyToNamespaces.get(key) || new Set([defaultNamespace]))
|
|
915
|
+
const savedLocales = new Set<string>([language])
|
|
916
|
+
|
|
917
|
+
if (openai) {
|
|
918
|
+
config.locales.forEach(locale => {
|
|
919
|
+
if (locale !== language) {
|
|
920
|
+
const translated = translationCache.get(`${language}:${locale}`)
|
|
921
|
+
if (translated?.[key]) {
|
|
922
|
+
savedLocales.add(locale)
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
})
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return {
|
|
929
|
+
key,
|
|
930
|
+
value,
|
|
931
|
+
namespace: namespaces.join(", "),
|
|
932
|
+
locale: Array.from(savedLocales).sort().join(", "),
|
|
933
|
+
}
|
|
934
|
+
})
|
|
817
935
|
|
|
818
936
|
return {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
937
|
+
results,
|
|
938
|
+
performance: {
|
|
939
|
+
totalTime,
|
|
940
|
+
scanTime,
|
|
941
|
+
translationTime,
|
|
942
|
+
fileIOTime: fileIOTime + writeTime,
|
|
943
|
+
},
|
|
823
944
|
}
|
|
824
945
|
}
|
|
946
|
+
|
|
947
|
+
export const addTranslationKey = async ({
|
|
948
|
+
key,
|
|
949
|
+
value,
|
|
950
|
+
language = "en",
|
|
951
|
+
config,
|
|
952
|
+
}: {
|
|
953
|
+
key: string
|
|
954
|
+
value: string
|
|
955
|
+
language?: string
|
|
956
|
+
config: Configuration
|
|
957
|
+
}) => {
|
|
958
|
+
const startTime = performance.now()
|
|
959
|
+
const result = await addTranslationKeys({
|
|
960
|
+
keys: [{ key, value, language }],
|
|
961
|
+
config,
|
|
962
|
+
})
|
|
963
|
+
const totalTime = performance.now() - startTime
|
|
964
|
+
|
|
965
|
+
const log = console.log
|
|
966
|
+
log(`⏱️ Single key operation completed in ${totalTime.toFixed(2)}ms`)
|
|
967
|
+
|
|
968
|
+
return result.results[0]
|
|
969
|
+
}
|