diff --git a/bin/test-translations.js b/bin/test-translations.js new file mode 100644 index 00000000000..158f81578f1 --- /dev/null +++ b/bin/test-translations.js @@ -0,0 +1,285 @@ +const path = require('path') +const fs = require('fs') +const parser = require('@babel/parser') +const traverse = require('@babel/traverse').default + +/* + Inspects .ts, .js, .tsx, .jsx files in all apps inside folders "domain", "pages" + Searching for occurrences like { messageForUser: } ; i18n() ; itntl.formatMessage({ id: }) + Does not work if isn't string + Saves output in ./bin/.local/test.json in format { + [pathToApp]: { + {filePath, lang, translationKey, message} + } + } + where message = + 'Code references undefined translation' - code translates key, which not present in en.json | ru.json + || + 'Unused translation' - translation exists in ru.json | en.json, but isn't used in code + */ + +const APPS_TO_EXCLUDE = [ + 'address-service' +] + +const FILE_EXTENSIONS_TO_INCLUDE = [ + 'js', + 'ts', + 'jsx', + 'tsx', +] + +function getDirectoriesNames (source) { + return fs.readdirSync(source, {withFileTypes: true}) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) +} + +function getLanguageJSONPaths (source) { + const subFiles = fs.readdirSync(source, {withFileTypes: true}) + let enJson = subFiles.find(v => v.name === 'en.json') + let ruJson = subFiles.find(v => v.name === 'ru.json') + + const throwIfNotBothFilesDefined = (ruJson, enJson) => { + if (ruJson === !enJson) { + throw new Error(`Does not have both translation files at ${source}`) + } + } + + const pathToTranslation = (source, name) => { + return path.join(source, name) + } + + const formatResult = (ruJson, enJson) => { + return { + en: path.join(enJson.parentPath, enJson.name), + ru: path.join(ruJson.parentPath, ruJson.name), + } + } + + if (!enJson && !ruJson) { + const enJsonFolderPath = path.join(source, 'en') + const ruJsonFolderPath = path.join(source, 'ru') + const enJsonFolder = fs.readdirSync(enJsonFolderPath, {withFileTypes: true}) + const ruJsonFolder = fs.readdirSync(ruJsonFolderPath, {withFileTypes: true}) + enJson = enJsonFolder.find(v => v.name === 'en.json') + ruJson = ruJsonFolder.find(v => v.name === 'ru.json') + throwIfNotBothFilesDefined(ruJson, enJson) + enJson.parentPath = enJsonFolderPath + ruJson.parentPath = ruJsonFolderPath + return formatResult(ruJson, enJson) + } else if (enJson && ruJson) { + enJson.parentPath = source + ruJson.parentPath = source + return formatResult(ruJson, enJson) + } + + throwIfNotBothFilesDefined(ruJson, enJson) +} + +function parseFileToJson (source) { + const data = fs.readFileSync(source, 'utf-8') + if (!data) { + return {} + } + return JSON.parse(data) +} + +function getAllFilePathsSync(dir) { + const filePaths = []; + + function traverse(dirPath, firstLevel = true) { + const dirents = fs.readdirSync(dirPath, { withFileTypes: true }) + dirents + .forEach(dirent => { + const fullPath = path.join(dirPath, dirent.name) + if (firstLevel && !['domains', 'pages'].includes(dirent.name)) { + return + } + if (dirent.isDirectory()) { + traverse(fullPath, false) + } else if (FILE_EXTENSIONS_TO_INCLUDE.some(ext => dirent.name.endsWith(`.${ext}`))) { + filePaths.push(fullPath) + } + }); + } + + traverse(dir) + return filePaths +} + +const STATE = {errors: 0, processed: 0, complete: 0} + +function findTranslationKeysInCode (filePath) { + const contents = fs.readFileSync(filePath, 'utf-8') + let ast + try { + const sourceType = filePath.endsWith('.js') ? 'script' : 'module' + ast = parser.parse(contents, { + allowImportExportEverywhere: true, + allowAwaitOutsideFunction: true, + allowReturnOutsideFunction: true, + allowNewTargetOutsideFunction: true, + allowSuperOutsideMethod: true, + allowUndeclaredExports: true, + attachComment: false, + errorRecovery: false, + sourceFilename: filePath, + sourceType: 'unambiguous', + plugins: ['typescript', 'jsx'], + }) + } catch (e) { + console.error('Error parsing tree', e) + STATE.errors += 1 + debugger; + return [] + } + const translationKeys = new Set() + const isAstValid = ast && (ast.type !== 'Program' || ast.type === 'File') + if (!isAstValid) { + return [] + } + + try { + traverse(ast, { + ObjectExpression(path) { + path.node.properties.forEach((property) => { + // in cases {...anotherObj}, {[someKey]: 'someValue'}, dynamic and spreaded keys checking is harder + if (!property.key) { + return + } + const keyName = property.key.type === 'Identifier' + ? property.key.name + : property.key.type === 'StringLiteral' + ? property.key.value + : null; + + if (keyName === 'messageForUser') { + if (property.value.type === 'StringLiteral') { + translationKeys.add(property.value.value); + } + } + }); + }, + + CallExpression(path) { + const callee = path.node.callee; + + if (callee.type === 'Identifier' && callee.name === 'i18n' && path.node.arguments.length > 0) { + const arg = path.node.arguments[0]; + + if (arg.type === 'StringLiteral') { + translationKeys.add(arg.value); + } + } + + // Check if the call is to `intl.formatMessage` + if ( + callee.type === 'MemberExpression' && + callee.object.name === 'intl' && + callee.property.name === 'formatMessage' && + path.node.arguments.length > 0 + ) { + const firstArg = path.node.arguments[0]; + + // Ensure the first argument is an object + if (firstArg.type === 'ObjectExpression') { + // Traverse the properties of the object + firstArg.properties.forEach((property) => { + // Check if there's an `id` property with the value `AskForAccessButton` + if ( + property.key.name === 'id' && + property.value.type === 'StringLiteral' + ) { + translationKeys.add(property.value.value) + } + }); + } + } + } + }) + } catch (e) { + console.error(e) + console.error(filePath, `typescript=${filePath.endsWith('.ts')}`) + console.error(ast) + STATE.errors += 1 + return [] + } + + STATE.complete += 1 + + return [...translationKeys] +} + +function compareTranslationKeys (appFolderPath, translations) { + const allFilePaths = getAllFilePathsSync(appFolderPath) + const translationKeys = new Set() + const errors = [] + for (const filePath of allFilePaths) { + const translationKeysInFile = findTranslationKeysInCode(filePath) + STATE.processed += 1 + for (const translationKey of translationKeysInFile) { + translationKeys.add(translationKey) + for (const lang of ['en', 'ru']) { + if (!translations[lang][translationKey]) { + errors.push({filePath, lang, translationKey, message: 'Code references undefined translation'}) + } + } + } + } + + for (const lang of ['en', 'ru']) { + const translationMap = translations[lang] + for (const translationKey in translationMap) { + if (!translationKeys.has(translationKey)) { + errors.push({appFolderPath, lang, translationKey, message: 'Unused translation'}) + } + } + } + + return errors +} + +const errors = {} + +function testApp(source) { + let langPaths + try { + langPaths = getLanguageJSONPaths(path.join(source, 'lang')) + } catch { + console.error('No "lang" folder for', source) + return + } + const translations = { + ru: parseFileToJson(langPaths.ru), + en: parseFileToJson(langPaths.en), + } + errors[source] = compareTranslationKeys(source, translations) +} + +async function main () { + const appsDir = path.join(__dirname, '..', 'apps') + const appsNames = getDirectoriesNames(appsDir).filter(name => !APPS_TO_EXCLUDE.includes(name)) + + for (const appName of appsNames) { + console.log('TEST', appName) + testApp(path.join(appsDir, appName)) + } + + console.log(errors) + console.log(STATE) + + const binPath = path.join(appsDir, '..', 'bin') + if (!fs.existsSync(path.join(binPath, '.local'))) { + fs.mkdirSync(path.join(binPath, '.local')) + } + fs.writeFileSync(path.join(binPath,'.local','test.json'), JSON.stringify(errors), {encoding: 'utf-8'}) + process.exit(0) +} + +main().catch((e) => { + console.error(e) + console.log(errors) + console.log(STATE) + process.exit(1) +}) \ No newline at end of file diff --git a/package.json b/package.json index 24370f7cd1f..fb08b0fa77f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "prepare": "husky install" }, "devDependencies": { + "@babel/parser": "^7.25.7", "@babel/plugin-proposal-private-methods": "^7.18.6", + "@babel/traverse": "^7.25.7", "@commitlint/cli": "^17.1.2", "@commitlint/config-conventional": "^17.1.0", "@faker-js/faker": "^7.6.0", diff --git a/yarn.lock b/yarn.lock index 19248b5cf18..da173fe46bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3880,6 +3880,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.25.7": + version: 7.25.7 + resolution: "@babel/helper-string-parser@npm:7.25.7" + checksum: 0835fda5efe02cdcb5144a939b639acc017ba4aa1cc80524b44032ddb714080d3e40e8f0d3240832b7bd86f5513f0b63d4fe77d8fc52d8c8720ae674182c0753 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.10.4, @babel/helper-validator-identifier@npm:^7.12.11, @babel/helper-validator-identifier@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-validator-identifier@npm:7.18.6" @@ -3908,6 +3915,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.25.7": + version: 7.25.7 + resolution: "@babel/helper-validator-identifier@npm:7.25.7" + checksum: 062f55208deead4876eb474dc6fd55155c9eada8d0a505434de3b9aa06c34195562e0f3142b22a08793a38d740238efa2fe00ff42956cdcb8ac03f0b6c542247 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-validator-option@npm:7.18.6" @@ -4155,6 +4169,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.25.7": + version: 7.25.7 + resolution: "@babel/parser@npm:7.25.7" + dependencies: + "@babel/types": ^7.25.7 + bin: + parser: ./bin/babel-parser.js + checksum: 7c40c2881e92415f5f2a88ac1078a8fea7f2b10097e76116ce40bfe01443d3a842c704bdb64d7b54c9e9dbbf49a60a0e1cf79ff35bcd02c52ff424179acd4259 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.3": version: 7.25.3 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.3" @@ -7815,6 +7840,17 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.25.7": + version: 7.25.7 + resolution: "@babel/types@npm:7.25.7" + dependencies: + "@babel/helper-string-parser": ^7.25.7 + "@babel/helper-validator-identifier": ^7.25.7 + to-fast-properties: ^2.0.0 + checksum: a63a3ecdac5eb2fa10a75d50ec23d1560beed6c4037ccf478a430cc221ba9b8b3a55cfbaaefb6e997051728f3c02b44dcddb06de9a0132f164a0a597dd825731 + languageName: node + linkType: hard + "@base2/pretty-print-object@npm:1.0.1": version: 1.0.1 resolution: "@base2/pretty-print-object@npm:1.0.1" @@ -49099,7 +49135,9 @@ __metadata: version: 0.0.0-use.local resolution: "root@workspace:." dependencies: + "@babel/parser": ^7.25.7 "@babel/plugin-proposal-private-methods": ^7.18.6 + "@babel/traverse": ^7.25.7 "@commitlint/cli": ^17.1.2 "@commitlint/config-conventional": ^17.1.0 "@faker-js/faker": ^7.6.0