UNPKG

@scoutello/i18n-magic

Version:

Intelligent CLI toolkit that automates internationalization workflows with AI-powered translations for JavaScript/TypeScript projects

347 lines 15.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TranslationError = exports.checkAllKeysExist = exports.getTextInput = exports.getMissingKeys = exports.getKeysWithNamespaces = exports.getGlobPatternsForNamespace = exports.getNamespacesForFile = exports.extractGlobPatterns = exports.getPureKey = exports.writeLocalesFile = exports.loadLocalesFile = exports.translateKey = exports.loadConfig = void 0; exports.removeDuplicatesFromArray = removeDuplicatesFromArray; const fast_glob_1 = __importDefault(require("fast-glob")); const i18next_scanner_1 = require("i18next-scanner"); const minimatch_1 = require("minimatch"); const node_fs_1 = __importDefault(require("node:fs")); const node_path_1 = __importDefault(require("node:path")); const prompts_1 = __importDefault(require("prompts")); const languges_1 = require("./languges"); const loadConfig = ({ configPath = "i18n-magic.js", }) => { const filePath = node_path_1.default.join(process.cwd(), configPath); if (!node_fs_1.default.existsSync(filePath)) { console.error("Config file does not exist:", filePath); process.exit(1); } try { const config = require(filePath); // Validate config if needed return config; } catch (error) { console.error("Error while loading config:", error); process.exit(1); } }; exports.loadConfig = loadConfig; function removeDuplicatesFromArray(arr) { return arr.filter((item, index) => arr.indexOf(item) === index); } const translateKey = async ({ inputLanguage, context, object, openai, outputLanguage, model, }) => { // Split object into chunks of 100 keys const entries = Object.entries(object); const chunks = []; for (let i = 0; i < entries.length; i += 100) { chunks.push(entries.slice(i, i + 100)); } let result = {}; const existingInput = languges_1.languages.find((l) => l.value === inputLanguage); const existingOutput = languges_1.languages.find((l) => l.value === outputLanguage); const input = existingInput?.label || inputLanguage; const output = existingOutput?.label || outputLanguage; // Translate each chunk for (const chunk of chunks) { const chunkObject = Object.fromEntries(chunk); const completion = await openai.beta.chat.completions.parse({ model, messages: [ { content: `You are a bot that translates the values of a locales JSON. ${context ? `The user provided some additional context or guidelines about what to fill in the blanks: \"${context}\". ` : ""}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"}.`, role: "system", }, { content: JSON.stringify({ inputLanguage: input, outputLanguage: output, data: chunkObject, }), role: "user", }, ], response_format: { type: "json_object", }, }); const translatedChunk = JSON.parse(completion.choices[0].message.content); // Merge translated chunk with result result = { ...result, ...translatedChunk }; // Optional: Add a small delay between chunks to avoid rate limiting await new Promise((resolve) => setTimeout(resolve, 100)); } return result; }; exports.translateKey = translateKey; const loadLocalesFile = async (loadPath, locale, namespace) => { if (typeof loadPath === "string") { const resolvedPath = loadPath .replace("{{lng}}", locale) .replace("{{ns}}", namespace); // Check if file exists, return empty object if it doesn't if (!node_fs_1.default.existsSync(resolvedPath)) { console.log(`📄 Creating new namespace file: ${resolvedPath}`); return {}; } const content = node_fs_1.default.readFileSync(resolvedPath, "utf-8"); try { const json = JSON.parse(content); return json; } catch (error) { throw new TranslationError(`Invalid JSON in locale file for ${locale}:${namespace}. Path: ${resolvedPath}`, locale, namespace, error instanceof Error ? error : undefined); } } return loadPath(locale, namespace); }; exports.loadLocalesFile = loadLocalesFile; const writeLocalesFile = async (savePath, locale, namespace, data) => { if (typeof savePath === "string") { const resolvedSavePath = savePath .replace("{{lng}}", locale) .replace("{{ns}}", namespace); // Ensure directory exists const dir = node_path_1.default.dirname(resolvedSavePath); if (!node_fs_1.default.existsSync(dir)) { node_fs_1.default.mkdirSync(dir, { recursive: true }); } node_fs_1.default.writeFileSync(resolvedSavePath, JSON.stringify(data, null, 2)); return; } await savePath(locale, namespace, data); }; exports.writeLocalesFile = writeLocalesFile; const getPureKey = (key, namespace, isDefault) => { const splitted = key.split(":"); if (splitted.length === 1) { if (isDefault) { return key; } return null; } if (splitted[0] === namespace) { return splitted[1]; } return null; }; exports.getPureKey = getPureKey; /** * Extracts all glob patterns from the configuration, handling both string and object formats */ const extractGlobPatterns = (globPatterns) => { return globPatterns.map((pattern) => typeof pattern === "string" ? pattern : pattern.pattern); }; exports.extractGlobPatterns = extractGlobPatterns; /** * Gets the namespaces associated with a specific file path based on glob pattern configuration */ const getNamespacesForFile = (filePath, globPatterns, defaultNamespace) => { const matchingNamespaces = []; // Normalize the file path - remove leading ./ if present const normalizedFilePath = filePath.replace(/^\.\//, ""); for (const pattern of globPatterns) { if (typeof pattern === "object") { // Normalize the pattern - remove leading ./ if present const normalizedPattern = pattern.pattern.replace(/^\.\//, ""); // Try matching with both the original and normalized paths/patterns const isMatch = (0, minimatch_1.minimatch)(filePath, pattern.pattern) || (0, minimatch_1.minimatch)(normalizedFilePath, pattern.pattern) || (0, minimatch_1.minimatch)(filePath, normalizedPattern) || (0, minimatch_1.minimatch)(normalizedFilePath, normalizedPattern); // Debug logging to help identify the issue if (process.env.DEBUG_NAMESPACE_MATCHING) { console.log(`Checking file: ${filePath} (normalized: ${normalizedFilePath})`); console.log(`Against pattern: ${pattern.pattern} (normalized: ${normalizedPattern})`); console.log(`Match result: ${isMatch}`); console.log(`Namespaces: ${pattern.namespaces.join(", ")}`); console.log("---"); } if (isMatch) { matchingNamespaces.push(...pattern.namespaces); } } } // If no specific namespaces found, use default namespace return matchingNamespaces.length > 0 ? [...new Set(matchingNamespaces)] : [defaultNamespace]; }; exports.getNamespacesForFile = getNamespacesForFile; /** * Gets all glob patterns that should be used for a specific namespace */ const getGlobPatternsForNamespace = (namespace, globPatterns) => { const patterns = []; for (const pattern of globPatterns) { if (typeof pattern === "string") { // String patterns apply to all namespaces patterns.push(pattern); } else if (pattern.namespaces.includes(namespace)) { // Object patterns only apply to specified namespaces patterns.push(pattern.pattern); } } return patterns; }; exports.getGlobPatternsForNamespace = getGlobPatternsForNamespace; /** * Extracts keys with their associated namespaces based on the files they're found in */ const getKeysWithNamespaces = async ({ globPatterns, defaultNamespace, }) => { const parser = new i18next_scanner_1.Parser({ nsSeparator: false, keySeparator: false, }); const allPatterns = (0, exports.extractGlobPatterns)(globPatterns); const files = await (0, fast_glob_1.default)([...allPatterns, "!**/node_modules/**"]); // Debug logging if (process.env.DEBUG_NAMESPACE_MATCHING) { console.log(`Found ${files.length} files matching patterns:`); for (const file of files.slice(0, 10)) { console.log(` ${file}`); } if (files.length > 10) console.log(` ... and ${files.length - 10} more`); console.log("---"); } const keysWithNamespaces = []; for (const file of files) { const content = node_fs_1.default.readFileSync(file, "utf-8"); const fileKeys = []; parser.parseFuncFromString(content, { list: ["t"] }, (key) => { fileKeys.push(key); }); // Get namespaces for this file const fileNamespaces = (0, exports.getNamespacesForFile)(file, globPatterns, defaultNamespace); // Debug logging if (process.env.DEBUG_NAMESPACE_MATCHING && fileKeys.length > 0) { console.log(`File: ${file}`); console.log(`Keys found: ${fileKeys.length}`); console.log(`Assigned namespaces: ${fileNamespaces.join(", ")}`); console.log("---"); } // Add each key with its associated namespaces for (const key of fileKeys) { keysWithNamespaces.push({ key, namespaces: fileNamespaces, file, }); } } return keysWithNamespaces; }; exports.getKeysWithNamespaces = getKeysWithNamespaces; const getMissingKeys = async ({ globPatterns, namespaces, defaultNamespace, defaultLocale, loadPath, }) => { const keysWithNamespaces = await (0, exports.getKeysWithNamespaces)({ globPatterns, defaultNamespace, }); const newKeys = []; console.log(`🔍 Found ${keysWithNamespaces.length} total key instances`); // Group keys by namespace const keysByNamespace = {}; for (const { key, namespaces: keyNamespaces } of keysWithNamespaces) { for (const namespace of keyNamespaces) { if (!keysByNamespace[namespace]) { keysByNamespace[namespace] = new Set(); } const pureKey = (0, exports.getPureKey)(key, namespace, namespace === defaultNamespace); if (pureKey) { keysByNamespace[namespace].add(pureKey); } } } // Show summary of keys by namespace for (const [namespace, keys] of Object.entries(keysByNamespace)) { console.log(`📦 ${namespace}: ${keys.size} unique keys`); } // Check for missing keys in each namespace for (const namespace of namespaces) { const existingKeys = await (0, exports.loadLocalesFile)(loadPath, defaultLocale, namespace); console.log(Object.keys(existingKeys).length, "existing keys in", namespace); const keysForNamespace = keysByNamespace[namespace] || new Set(); console.log(`🔍 Checking ${keysForNamespace.size} keys for namespace ${namespace}`); for (const key of keysForNamespace) { if (!existingKeys[key]) { newKeys.push({ key, namespace }); } } } return newKeys; }; exports.getMissingKeys = getMissingKeys; const getTextInput = async (prompt) => { const input = await (0, prompts_1.default)({ name: "value", type: "text", message: prompt, onState: (state) => { if (state.aborted) { process.nextTick(() => { process.exit(0); }); } }, }); return input.value; }; exports.getTextInput = getTextInput; const checkAllKeysExist = async ({ namespaces, defaultLocale, loadPath, locales, context, openai, savePath, disableTranslation, model, }) => { if (disableTranslation) { return; } for (const namespace of namespaces) { const defaultLocaleKeys = await (0, exports.loadLocalesFile)(loadPath, defaultLocale, namespace); for (const locale of locales) { if (locale === defaultLocale) continue; const localeKeys = await (0, exports.loadLocalesFile)(loadPath, locale, namespace); const missingKeys = {}; // Check which keys from default locale are missing in current locale for (const [key, value] of Object.entries(defaultLocaleKeys)) { if (!localeKeys[key]) { missingKeys[key] = value; } } // If there are missing keys, translate them if (Object.keys(missingKeys).length > 0) { console.log(`Found ${Object.keys(missingKeys).length} missing keys in ${locale} (namespace: ${namespace})`); const translatedValues = await (0, exports.translateKey)({ inputLanguage: defaultLocale, outputLanguage: locale, context, object: missingKeys, openai, model, }); // Merge translated values with existing ones const updatedLocaleKeys = { ...localeKeys, ...translatedValues, }; // Save the updated translations (0, exports.writeLocalesFile)(savePath, locale, namespace, updatedLocaleKeys); console.log(`✓ Translated and saved missing keys for ${locale} (namespace: ${namespace})`); } } } }; exports.checkAllKeysExist = checkAllKeysExist; class TranslationError extends Error { constructor(message, locale, namespace, cause) { super(message); this.locale = locale; this.namespace = namespace; this.cause = cause; this.name = "TranslationError"; } } exports.TranslationError = TranslationError; //# sourceMappingURL=utils.js.map