@scoutello/i18n-magic
Version:
Intelligent CLI toolkit that automates internationalization workflows with AI-powered translations for JavaScript/TypeScript projects
501 lines (428 loc) • 13.8 kB
text/typescript
import glob from "fast-glob"
import { Parser } from "i18next-scanner"
import { minimatch } from "minimatch"
import fs from "node:fs"
import path from "node:path"
import type OpenAI from "openai"
import prompts from "prompts"
import { languages } from "./languges"
import type { Configuration, GlobPatternConfig } from "./types"
export const loadConfig = ({
configPath = "i18n-magic.js",
}: { configPath: string }) => {
const filePath = path.join(process.cwd(), configPath)
if (!fs.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)
}
}
export function removeDuplicatesFromArray<T>(arr: T[]): T[] {
return arr.filter((item, index) => arr.indexOf(item) === index)
}
export const translateKey = async ({
inputLanguage,
context,
object,
openai,
outputLanguage,
model,
}: {
object: Record<string, string>
context: string
inputLanguage: string
outputLanguage: string
model: string
openai: OpenAI
}) => {
// Split object into chunks of 100 keys
const entries = Object.entries(object)
const chunks: Array<[string, string][]> = []
for (let i = 0; i < entries.length; i += 100) {
chunks.push(entries.slice(i, i + 100))
}
let result: Record<string, string> = {}
const existingInput = languages.find((l) => l.value === inputLanguage)
const existingOutput = 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,
) as Record<string, string>
// 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
}
export const loadLocalesFile = async (
loadPath:
| string
| ((locale: string, namespace: string) => Promise<Record<string, string>>),
locale: string,
namespace: string,
) => {
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 (!fs.existsSync(resolvedPath)) {
console.log(`📄 Creating new namespace file: ${resolvedPath}`)
return {}
}
const content = fs.readFileSync(resolvedPath, "utf-8")
try {
const json = JSON.parse(content)
return json as Record<string, string>
} 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)
}
export const writeLocalesFile = async (
savePath:
| string
| ((
locale: string,
namespace: string,
data: Record<string, string>,
) => Promise<void>),
locale: string,
namespace: string,
data: Record<string, string>,
) => {
if (typeof savePath === "string") {
const resolvedSavePath = savePath
.replace("{{lng}}", locale)
.replace("{{ns}}", namespace)
// Ensure directory exists
const dir = path.dirname(resolvedSavePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(resolvedSavePath, JSON.stringify(data, null, 2))
return
}
await savePath(locale, namespace, data)
}
export const getPureKey = (
key: string,
namespace?: string,
isDefault?: boolean,
) => {
const splitted = key.split(":")
if (splitted.length === 1) {
if (isDefault) {
return key
}
return null
}
if (splitted[0] === namespace) {
return splitted[1]
}
return null
}
/**
* Extracts all glob patterns from the configuration, handling both string and object formats
*/
export const extractGlobPatterns = (
globPatterns: (string | GlobPatternConfig)[],
): string[] => {
return globPatterns.map((pattern) =>
typeof pattern === "string" ? pattern : pattern.pattern,
)
}
/**
* Gets the namespaces associated with a specific file path based on glob pattern configuration
*/
export const getNamespacesForFile = (
filePath: string,
globPatterns: (string | { pattern: string; namespaces: string[] })[],
defaultNamespace: string,
): string[] => {
const matchingNamespaces: string[] = []
// 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 =
minimatch(filePath, pattern.pattern) ||
minimatch(normalizedFilePath, pattern.pattern) ||
minimatch(filePath, normalizedPattern) ||
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]
}
/**
* Gets all glob patterns that should be used for a specific namespace
*/
export const getGlobPatternsForNamespace = (
namespace: string,
globPatterns: (string | { pattern: string; namespaces: string[] })[],
): string[] => {
const patterns: string[] = []
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
}
/**
* Extracts keys with their associated namespaces based on the files they're found in
*/
export const getKeysWithNamespaces = async ({
globPatterns,
defaultNamespace,
}: Pick<Configuration, "globPatterns" | "defaultNamespace">) => {
const parser = new Parser({
nsSeparator: false,
keySeparator: false,
})
const allPatterns = extractGlobPatterns(globPatterns)
const files = await glob([...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: Array<{
key: string
namespaces: string[]
file: string
}> = []
for (const file of files) {
const content = fs.readFileSync(file, "utf-8")
const fileKeys: string[] = []
parser.parseFuncFromString(content, { list: ["t"] }, (key: string) => {
fileKeys.push(key)
})
// Get namespaces for this file
const fileNamespaces = 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
}
export const getMissingKeys = async ({
globPatterns,
namespaces,
defaultNamespace,
defaultLocale,
loadPath,
}: Configuration) => {
const keysWithNamespaces = await getKeysWithNamespaces({
globPatterns,
defaultNamespace,
})
const newKeys = []
console.log(`🔍 Found ${keysWithNamespaces.length} total key instances`)
// Group keys by namespace
const keysByNamespace: Record<string, Set<string>> = {}
for (const { key, namespaces: keyNamespaces } of keysWithNamespaces) {
for (const namespace of keyNamespaces) {
if (!keysByNamespace[namespace]) {
keysByNamespace[namespace] = new Set()
}
const pureKey = 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 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
}
export const getTextInput = async (prompt: string) => {
const input = await prompts({
name: "value",
type: "text",
message: prompt,
onState: (state) => {
if (state.aborted) {
process.nextTick(() => {
process.exit(0)
})
}
},
})
return input.value as string
}
export const checkAllKeysExist = async ({
namespaces,
defaultLocale,
loadPath,
locales,
context,
openai,
savePath,
disableTranslation,
model,
}: Configuration) => {
if (disableTranslation) {
return
}
for (const namespace of namespaces) {
const defaultLocaleKeys = await loadLocalesFile(
loadPath,
defaultLocale,
namespace,
)
for (const locale of locales) {
if (locale === defaultLocale) continue
const localeKeys = await loadLocalesFile(loadPath, locale, namespace)
const missingKeys: Record<string, string> = {}
// 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 translateKey({
inputLanguage: defaultLocale,
outputLanguage: locale,
context,
object: missingKeys,
openai,
model,
})
// Merge translated values with existing ones
const updatedLocaleKeys = {
...localeKeys,
...translatedValues,
}
// Save the updated translations
writeLocalesFile(savePath, locale, namespace, updatedLocaleKeys)
console.log(
`✓ Translated and saved missing keys for ${locale} (namespace: ${namespace})`,
)
}
}
}
}
export class TranslationError extends Error {
constructor(
message: string,
public locale?: string,
public namespace?: string,
public cause?: Error,
) {
super(message)
this.name = "TranslationError"
}
}