UNPKG

i18n-ai-translate

Version:

Use LLMs to translate your i18n JSON to any language.

927 lines (821 loc) 32.5 kB
import { DEFAULT_BATCH_SIZE, DEFAULT_TEMPLATED_STRING_PREFIX, DEFAULT_TEMPLATED_STRING_SUFFIX, FLATTEN_DELIMITER, } from "./constants"; import { distance } from "fastest-levenshtein"; import { flatten, unflatten } from "flat"; import { getAllFilesInPath, getLanguageCodeFromFilename, getTranslationDirectoryKey, } from "./utils"; import ChatFactory from "./chat_interface/chat_factory"; import RateLimiter from "./rate_limiter"; import fs from "fs"; import generateTranslation from "./generate"; import path, { dirname } from "path"; import type Chats from "./interfaces/chats"; import type TranslateDiffOptions from "./interfaces/translate_diff_options"; import type TranslateDirectoryDiffOptions from "./interfaces/translate_directory_diff_options"; import type TranslateDirectoryOptions from "./interfaces/translate_directory_options"; import type TranslateFileDiffOptions from "./interfaces/translate_file_diff_options"; import type TranslateFileOptions from "./interfaces/translate_file_options"; import type TranslateOptions from "./interfaces/translate_options"; /** * Translate the input JSON to the given language * @param options - The options for the translation */ export async function translate(options: TranslateOptions): Promise<Object> { if (options.verbose) { console.log( `Translating from ${options.inputLanguage} to ${options.outputLanguage}...`, ); } const rateLimiter = new RateLimiter( options.rateLimitMs, options.verbose ?? false, ); const chats: Chats = { generateTranslationChat: ChatFactory.newChat( options.engine, options.model, rateLimiter, options.apiKey, options.host, ), verifyStylingChat: ChatFactory.newChat( options.engine, options.model, rateLimiter, options.apiKey, options.host, ), verifyTranslationChat: ChatFactory.newChat( options.engine, options.model, rateLimiter, options.apiKey, options.host, ), }; const output: { [key: string]: string } = {}; const templatedStringPrefix = options.templatedStringPrefix || DEFAULT_TEMPLATED_STRING_PREFIX; const templatedStringSuffix = options.templatedStringSuffix || DEFAULT_TEMPLATED_STRING_SUFFIX; let flatInput = flatten(options.inputJSON, { delimiter: FLATTEN_DELIMITER, }) as { [key: string]: string; }; for (const key in flatInput) { if (Object.prototype.hasOwnProperty.call(flatInput, key)) { flatInput[key] = flatInput[key].replaceAll( "\n", `${templatedStringPrefix}NEWLINE${templatedStringSuffix}`, ); } } const groups: Array<{ [key: string]: string }> = []; for (const key in flatInput) { if (Object.prototype.hasOwnProperty.call(flatInput, key)) { const val = flatInput[key]; const existingGroup = groups.find((group) => Object.values(group).some((entry) => { const distPercent = distance(val, entry) / Math.max(val.length, entry.length); return distPercent < 0.3; }), ); if (existingGroup) { existingGroup[key] = val; } else { groups.push({ [key]: val }); } } } for (let i = groups.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [groups[i], groups[j]] = [groups[j], groups[i]]; } flatInput = {}; for (const groupObj of groups) { for (const [k, v] of Object.entries(groupObj)) { flatInput[k] = v; } } const allKeys = Object.keys(flatInput); const batchSize = Number(options.batchSize ?? DEFAULT_BATCH_SIZE); const batchStartTime = Date.now(); for (let i = 0; i < Object.keys(flatInput).length; i += batchSize) { if (i > 0 && options.verbose) { console.log( `Completed ${((i / Object.keys(flatInput).length) * 100).toFixed(0)}%`, ); const roundedEstimatedTimeLeftSeconds = Math.round( (((Date.now() - batchStartTime) / (i + 1)) * (Object.keys(flatInput).length - i)) / 1000, ); console.log( `Estimated time left: ${roundedEstimatedTimeLeftSeconds} seconds`, ); } const keys = allKeys.slice(i, i + batchSize); const input = keys.map((x) => `"${flatInput[x]}"`).join("\n"); // eslint-disable-next-line no-await-in-loop const generatedTranslation = await generateTranslation({ chats, ensureChangedTranslation: options.ensureChangedTranslation ?? false, input, inputLanguage: `[${options.inputLanguage}]`, keys, outputLanguage: `[${options.outputLanguage}]`, overridePrompt: options.overridePrompt, skipStylingVerification: options.skipStylingVerification ?? false, skipTranslationVerification: options.skipTranslationVerification ?? false, templatedStringPrefix, templatedStringSuffix, verboseLogging: options.verbose ?? false, }); if (generatedTranslation === "") { console.error( `Failed to generate translation for ${options.outputLanguage}`, ); break; } for (let j = 0; j < keys.length; j++) { output[keys[j]] = generatedTranslation.split("\n")[j].slice(1, -1); if (options.verbose) console.log( `${keys[j].replaceAll("*", ".")}:\n${flatInput[keys[j]]}\n=>\n${output[keys[j]]}\n`, ); } } // sort the keys const sortedOutput: { [key: string]: string } = {}; for (const key of Object.keys(flatInput).sort()) { sortedOutput[key] = output[key]; } for (const key in sortedOutput) { if (Object.prototype.hasOwnProperty.call(sortedOutput, key)) { sortedOutput[key] = sortedOutput[key].replaceAll( `${templatedStringPrefix}NEWLINE${templatedStringSuffix}`, "\n", ); } } const unflattenedOutput = unflatten(sortedOutput, { delimiter: FLATTEN_DELIMITER, }); if (options.verbose) { const endTime = Date.now(); const roundedSeconds = Math.round((endTime - batchStartTime) / 1000); console.log(`Actual execution time: ${roundedSeconds} seconds`); } return unflattenedOutput as Object; } /** * Translate the difference of an input JSON to the given languages * @param options - The options for the translation */ export async function translateDiff( options: TranslateDiffOptions, ): Promise<{ [language: string]: Object }> { const flatInputBefore = flatten(options.inputJSONBefore, { delimiter: FLATTEN_DELIMITER, }) as { [key: string]: string; }; const flatInputAfter = flatten(options.inputJSONAfter, { delimiter: FLATTEN_DELIMITER, }) as { [key: string]: string; }; const flatToUpdateJSONs: { [language: string]: { [key: string]: string } } = {}; for (const lang in options.toUpdateJSONs) { if (Object.prototype.hasOwnProperty.call(options.toUpdateJSONs, lang)) { const flatToUpdateJSON = flatten(options.toUpdateJSONs[lang], { delimiter: FLATTEN_DELIMITER, }) as { [key: string]: string; }; flatToUpdateJSONs[lang] = flatToUpdateJSON; } } const addedKeys = []; const modifiedKeys = []; const deletedKeys = []; for (const key in flatInputBefore) { if (flatInputBefore[key] !== flatInputAfter[key]) { if (flatInputAfter[key] === undefined) { deletedKeys.push(key); } else { modifiedKeys.push(key); } } } for (const key in flatInputAfter) { if (flatInputBefore[key] === undefined) { addedKeys.push(key); } } if (options.verbose) { console.log(`Added keys: ${addedKeys.join("\n")}\n`); console.log(`Modified keys: ${modifiedKeys.join("\n")}\n`); console.log(`Deleted keys: ${deletedKeys.join("\n")}\n`); } for (const key of deletedKeys) { for (const lang in flatToUpdateJSONs) { if (Object.prototype.hasOwnProperty.call(flatToUpdateJSONs, lang)) { delete flatToUpdateJSONs[lang][key]; } } } for (const languageCode in flatToUpdateJSONs) { if ( Object.prototype.hasOwnProperty.call( flatToUpdateJSONs, languageCode, ) ) { const addedAndModifiedTranslations: { [key: string]: string } = {}; for (const key of addedKeys) { addedAndModifiedTranslations[key] = flatInputAfter[key]; } for (const key of modifiedKeys) { addedAndModifiedTranslations[key] = flatInputAfter[key]; } // eslint-disable-next-line no-await-in-loop const translated = await translate({ apiKey: options.apiKey, batchSize: options.batchSize, chatParams: options.chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host: options.host, inputJSON: addedAndModifiedTranslations, inputLanguage: options.inputLanguage, model: options.model, outputLanguage: languageCode, overridePrompt: options.overridePrompt, rateLimitMs: options.rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, verbose: options.verbose, }); const flatTranslated = flatten(translated, { delimiter: FLATTEN_DELIMITER, }) as { [key: string]: string; }; for (const key in flatTranslated) { if (Object.prototype.hasOwnProperty.call(flatTranslated, key)) { flatToUpdateJSONs[languageCode][key] = flatTranslated[key]; } } // Sort the keys flatToUpdateJSONs[languageCode] = Object.keys( flatToUpdateJSONs[languageCode], ) .sort() .reduce( (obj, key) => { obj[key] = flatToUpdateJSONs[languageCode][key]; return obj; }, {} as { [key: string]: string }, ); } } const unflatToUpdateJSONs: { [language: string]: Object } = {}; for (const lang in flatToUpdateJSONs) { if (Object.prototype.hasOwnProperty.call(flatToUpdateJSONs, lang)) { unflatToUpdateJSONs[lang] = unflatten(flatToUpdateJSONs[lang], { delimiter: FLATTEN_DELIMITER, }); } } return unflatToUpdateJSONs; } /** * Wraps translate to take an input file and output its translation to another file * @param options - The file translation's options */ export async function translateFile( options: TranslateFileOptions, ): Promise<void> { let inputJSON = {}; try { const inputFile = fs.readFileSync(options.inputFilePath, "utf-8"); inputJSON = JSON.parse(inputFile); } catch (e) { console.error(`Invalid input JSON: ${e}`); return; } const inputLanguage = getLanguageCodeFromFilename(options.inputFilePath); let outputLanguage = ""; if (options.forceLanguageName) { outputLanguage = options.forceLanguageName; } else { outputLanguage = getLanguageCodeFromFilename(options.outputFilePath); } try { const outputJSON = await translate({ apiKey: options.apiKey, batchSize: options.batchSize, chatParams: options.chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host: options.host, inputJSON, inputLanguage, model: options.model, outputLanguage, overridePrompt: options.overridePrompt, rateLimitMs: options.rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, verbose: options.verbose, }); const outputText = JSON.stringify(outputJSON, null, 4); fs.writeFileSync(options.outputFilePath, `${outputText}\n`); } catch (err) { console.error(`Failed to translate file to ${outputLanguage}: ${err}`); } } /** * Wraps translateDiff to take two versions of a source file and update * the target translation's file by only modifying keys that changed in the source * @param options - The file diff translation's options */ export async function translateFileDiff( options: TranslateFileDiffOptions, ): Promise<void> { // Get all the *json files from the same path as beforeInputPath const outputFilesOrPaths = fs .readdirSync(path.dirname(options.inputBeforeFileOrPath)) .filter((file: string) => file.endsWith(".json")) .filter( (file) => file !== path.basename(options.inputBeforeFileOrPath) && file !== path.basename(options.inputAfterFileOrPath), ) .map((file) => path.resolve(path.dirname(options.inputBeforeFileOrPath), file), ); const jsonFolder = path.resolve(process.cwd(), "jsons"); let inputBeforePath: string; let inputAfterPath: string; if (path.isAbsolute(options.inputBeforeFileOrPath)) { inputBeforePath = path.resolve(options.inputBeforeFileOrPath); } else { inputBeforePath = path.resolve( jsonFolder, options.inputBeforeFileOrPath, ); if (!fs.existsSync(inputBeforePath)) { inputBeforePath = path.resolve( process.cwd(), options.inputBeforeFileOrPath, ); } } if (path.isAbsolute(options.inputAfterFileOrPath)) { inputAfterPath = path.resolve(options.inputAfterFileOrPath); } else { inputAfterPath = path.resolve(jsonFolder, options.inputAfterFileOrPath); } const outputPaths: Array<string> = []; for (const outputFileOrPath of outputFilesOrPaths) { let outputPath: string; if (path.isAbsolute(outputFileOrPath)) { outputPath = path.resolve(outputFileOrPath); } else { outputPath = path.resolve(jsonFolder, outputFileOrPath); if (!fs.existsSync(jsonFolder)) { outputPath = path.resolve(process.cwd(), outputFileOrPath); } } outputPaths.push(outputPath); } let inputBeforeJSON = {}; let inputAfterJSON = {}; try { let inputFile = fs.readFileSync(inputBeforePath, "utf-8"); inputBeforeJSON = JSON.parse(inputFile); inputFile = fs.readFileSync(inputAfterPath, "utf-8"); inputAfterJSON = JSON.parse(inputFile); } catch (e) { console.error(`Invalid input JSON: ${e}`); return; } const toUpdateJSONs: { [language: string]: Object } = {}; const languageCodeToOutputPath: { [language: string]: string } = {}; for (const outputPath of outputPaths) { const languageCode = getLanguageCodeFromFilename( path.basename(outputPath), ); if (!languageCode) { throw new Error( "Invalid output file name. Use a valid ISO 639-1 language code as the file name.", ); } try { const outputFile = fs.readFileSync(outputPath, "utf-8"); toUpdateJSONs[languageCode] = JSON.parse(outputFile); languageCodeToOutputPath[languageCode] = outputPath; } catch (e) { console.error(`Invalid output JSON: ${e}`); } } try { const outputJSON = await translateDiff({ apiKey: options.apiKey, batchSize: options.batchSize, chatParams: options.chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host: options.host, inputJSONAfter: inputAfterJSON, inputJSONBefore: inputBeforeJSON, inputLanguage: options.inputLanguageCode, model: options.model, overridePrompt: options.overridePrompt, rateLimitMs: options.rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, toUpdateJSONs, verbose: options.verbose, }); for (const language in outputJSON) { if (Object.prototype.hasOwnProperty.call(outputJSON, language)) { const outputText = JSON.stringify( outputJSON[language], null, 4, ); fs.writeFileSync( languageCodeToOutputPath[language], `${outputText}\n`, ); } } } catch (err) { console.error(`Failed to translate file diff: ${err}`); } } /** * Wraps translate to take all keys of all files in a directory and re-create the exact * directory structure and translations for the target language * @param options - The directory translation's options */ export async function translateDirectory( options: TranslateDirectoryOptions, ): Promise<void> { const jsonFolder = path.resolve(process.cwd(), "jsons"); let fullBasePath: string; if (path.isAbsolute(options.baseDirectory)) { fullBasePath = path.resolve(options.baseDirectory); } else { fullBasePath = path.resolve(jsonFolder, options.baseDirectory); if (!fs.existsSync(fullBasePath)) { fullBasePath = path.resolve(process.cwd(), options.baseDirectory); } } const sourceLanguagePath = path.resolve( fullBasePath, options.inputLanguage, ); if (!fs.existsSync(sourceLanguagePath)) { throw new Error( `Source language path does not exist. sourceLanguagePath = ${sourceLanguagePath}`, ); } const sourceFilePaths = getAllFilesInPath(sourceLanguagePath); const inputJSON: { [key: string]: string } = {}; for (const sourceFilePath of sourceFilePaths) { const fileContents = fs.readFileSync(sourceFilePath, "utf-8"); const fileJSON = JSON.parse(fileContents); const flatJSON = flatten(fileJSON, { delimiter: FLATTEN_DELIMITER, }) as { [key: string]: string; }; for (const key in flatJSON) { if (Object.prototype.hasOwnProperty.call(flatJSON, key)) { inputJSON[ getTranslationDirectoryKey( sourceFilePath, key, options.inputLanguage, options.outputLanguage, ) ] = flatJSON[key]; } } } const inputLanguage = getLanguageCodeFromFilename(options.inputLanguage); let outputLanguage = ""; if (options.forceLanguageName) { outputLanguage = options.forceLanguageName; } else { outputLanguage = getLanguageCodeFromFilename(options.outputLanguage); } try { const outputJSON = (await translate({ apiKey: options.apiKey, batchSize: options.batchSize, chatParams: options.chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host: options.host, inputJSON, inputLanguage, model: options.model, outputLanguage, overridePrompt: options.overridePrompt, rateLimitMs: options.rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, verbose: options.verbose, })) as { [filePathKey: string]: string }; const filesToJSON: { [filePath: string]: { [key: string]: string } } = {}; for (const pathWithKey in outputJSON) { if (Object.prototype.hasOwnProperty.call(outputJSON, pathWithKey)) { const filePath = pathWithKey.split(":").slice(0, -1).join(":"); if (!filesToJSON[filePath]) { filesToJSON[filePath] = {}; } const key = pathWithKey.split(":").pop()!; filesToJSON[filePath][key] = outputJSON[pathWithKey]; } } for (const perFileJSON in filesToJSON) { if ( Object.prototype.hasOwnProperty.call(filesToJSON, perFileJSON) ) { const unflattenedOutput = unflatten(filesToJSON[perFileJSON], { delimiter: FLATTEN_DELIMITER, }); const outputText = JSON.stringify(unflattenedOutput, null, 4); fs.mkdirSync(dirname(perFileJSON), { recursive: true }); fs.writeFileSync(perFileJSON, `${outputText}\n`); } } } catch (err) { console.error( `Failed to translate directory to ${outputLanguage}: ${err}`, ); } } /** * Wraps translateDiff to take the changed keys of all files in a directory * and write the translation of those keys in the target translation * @param options - The directory translation diff's options */ export async function translateDirectoryDiff( options: TranslateDirectoryDiffOptions, ): Promise<void> { const jsonFolder = path.resolve(process.cwd(), "jsons"); let fullBasePath: string; if (path.isAbsolute(options.baseDirectory)) { fullBasePath = path.resolve(options.baseDirectory); } else { fullBasePath = path.resolve(jsonFolder, options.baseDirectory); if (!fs.existsSync(fullBasePath)) { fullBasePath = path.resolve(process.cwd(), options.baseDirectory); } } const sourceLanguagePathBefore = path.resolve( fullBasePath, options.inputFolderNameBefore, ); const sourceLanguagePathAfter = path.resolve( fullBasePath, options.inputFolderNameAfter, ); if (!fs.existsSync(sourceLanguagePathBefore)) { throw new Error( `Source language path before does not exist. sourceLanguagePathBefore = ${sourceLanguagePathBefore}`, ); } if (!fs.existsSync(sourceLanguagePathAfter)) { throw new Error( `Source language path after does not exist. sourceLanguagePathAfter = ${sourceLanguagePathAfter}`, ); } // TODO: abstract to fn const sourceFilePathsBefore = getAllFilesInPath(sourceLanguagePathBefore); const inputJSONBefore: { [key: string]: string } = {}; for (const sourceFilePath of sourceFilePathsBefore) { const fileContents = fs.readFileSync(sourceFilePath, "utf-8"); const fileJSON = JSON.parse(fileContents); const flatJSON = flatten(fileJSON, { delimiter: FLATTEN_DELIMITER, }) as { [key: string]: string; }; for (const key in flatJSON) { if (Object.prototype.hasOwnProperty.call(flatJSON, key)) { inputJSONBefore[ getTranslationDirectoryKey( sourceFilePath, key, options.inputLanguageCode, ) ] = flatJSON[key]; } } } const sourceFilePathsAfter = getAllFilesInPath(sourceLanguagePathAfter); const inputJSONAfter: { [key: string]: string } = {}; for (const sourceFilePath of sourceFilePathsAfter) { const fileContents = fs.readFileSync(sourceFilePath, "utf-8"); const fileJSON = JSON.parse(fileContents); const flatJSON = flatten(fileJSON, { delimiter: FLATTEN_DELIMITER, }) as { [key: string]: string; }; for (const key in flatJSON) { if (Object.prototype.hasOwnProperty.call(flatJSON, key)) { inputJSONAfter[ getTranslationDirectoryKey( sourceFilePath.replace( options.inputFolderNameAfter, options.inputFolderNameBefore, ), key, options.inputLanguageCode, ) ] = flatJSON[key]; } } } const outputLanguagePaths = fs .readdirSync(options.baseDirectory) .filter( (folder) => folder !== path.basename(options.inputFolderNameBefore) && folder !== path.basename(options.inputFolderNameAfter), ) .map((folder) => path.resolve(options.baseDirectory, folder)); const toUpdateJSONs: { [languageCode: string]: { [key: string]: string } } = {}; for (const outputLanguagePath of outputLanguagePaths) { const files = getAllFilesInPath(outputLanguagePath); for (const file of files) { const fileContents = fs.readFileSync(file, "utf-8"); const fileJSON = JSON.parse(fileContents); const flatJSON = flatten(fileJSON, { delimiter: FLATTEN_DELIMITER, }) as { [key: string]: string; }; const relative = path.relative( options.baseDirectory, outputLanguagePath, ); const segments = relative.split(path.sep).filter(Boolean); const language = segments[0]; if (!toUpdateJSONs[language]) { toUpdateJSONs[language] = {}; } for (const key in flatJSON) { if (Object.prototype.hasOwnProperty.call(flatJSON, key)) { toUpdateJSONs[language][ getTranslationDirectoryKey( file.replace( outputLanguagePath, options.inputFolderNameBefore, ), key, options.inputLanguageCode, ) ] = flatJSON[key]; } } } } try { const perLanguageOutputJSON = await translateDiff({ apiKey: options.apiKey, batchSize: options.batchSize, chatParams: options.chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host: options.host, inputJSONAfter, inputJSONBefore, inputLanguage: options.inputLanguageCode, model: options.model, overridePrompt: options.overridePrompt, rateLimitMs: options.rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, toUpdateJSONs, verbose: options.verbose, }); const filesToJSON: { [filePath: string]: { [key: string]: string } } = {}; for (const outputLanguage in perLanguageOutputJSON) { if ( Object.prototype.hasOwnProperty.call( perLanguageOutputJSON, outputLanguage, ) ) { const outputJSON = perLanguageOutputJSON[outputLanguage] as { [key: string]: string; }; for (const pathWithKey in outputJSON) { if ( Object.prototype.hasOwnProperty.call( outputJSON, pathWithKey, ) ) { const filePath = pathWithKey .split(":") .slice(0, -1) .join(":") .replace( options.inputFolderNameBefore, `${options.baseDirectory}/${outputLanguage}`, ); if (!filesToJSON[filePath]) { filesToJSON[filePath] = {}; } const key = pathWithKey.split(":").pop()!; filesToJSON[filePath][key] = outputJSON[pathWithKey]; } } for (const perFileJSON in filesToJSON) { if ( Object.prototype.hasOwnProperty.call( filesToJSON, perFileJSON, ) ) { const unflattenedOutput = unflatten( filesToJSON[perFileJSON], { delimiter: FLATTEN_DELIMITER, }, ); const outputText = JSON.stringify( unflattenedOutput, null, 4, ); fs.mkdirSync(dirname(perFileJSON), { recursive: true }); fs.writeFileSync(perFileJSON, `${outputText}\n`); } } } } } catch (err) { console.error(`Failed to translate directory diff: ${err}`); } // Remove any files in before not in after const fileNamesBefore = sourceFilePathsBefore.map((x) => x.slice(sourceLanguagePathBefore.length), ); const fileNamesAfter = sourceFilePathsAfter.map((x) => x.slice(sourceLanguagePathAfter.length), ); const removedFiles = fileNamesBefore.filter( (x) => !fileNamesAfter.includes(x), ); for (const languagePath of outputLanguagePaths) { for (const removedFile of removedFiles) { const removedFilePath = languagePath + removedFile; fs.rmSync(removedFilePath); // Recursively cleanup parent folders if they're also empty let folder = path.dirname(removedFilePath); while (fs.readdirSync(folder).length === 0) { const parentFolder = path.resolve(folder, ".."); fs.rmdirSync(folder); folder = parentFolder; } } } }